v1.9.7: ask_user_input elicitation tool
This commit is contained in:
@@ -123,6 +123,9 @@ async function main() {
|
|||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
publishSessionFrame: (sessionId, frame) => {
|
||||||
|
broker.publish(sessionId, frame);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
registerSkillsRoutes(app, sql, {
|
registerSkillsRoutes(app, sql, {
|
||||||
enqueueInference: (sessionId, chatId, assistantId, user) => {
|
enqueueInference: (sessionId, chatId, assistantId, user) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import type { Chat, Message, Session } from '../types/api.js';
|
import type { Chat, Message, Session, ToolCall } from '../types/api.js';
|
||||||
|
|
||||||
const SendBody = z.object({
|
const SendBody = z.object({
|
||||||
content: z.string().min(1).max(64_000),
|
content: z.string().min(1).max(64_000),
|
||||||
@@ -14,6 +14,39 @@ const ContinueBody = z.object({
|
|||||||
sentinel_message_id: z.string().uuid(),
|
sentinel_message_id: z.string().uuid(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Batch 9.7: ask_user_input answer submission. Defensive shape — the question
|
||||||
|
// content is echoed back for traceability but the server does NOT trust it
|
||||||
|
// (the source of truth is the assistant message's tool_calls.args.questions).
|
||||||
|
const AnswerUserInputBody = z.object({
|
||||||
|
tool_call_id: z.string().min(1),
|
||||||
|
answers: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
question: z.string(),
|
||||||
|
selected_options: z.array(z.string()),
|
||||||
|
free_text: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(3),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same shape the model declared via the tool's zod input. Re-derived here so
|
||||||
|
// the route can validate args without depending on services/tools.ts (which
|
||||||
|
// would pull in fs/path_guard for nothing).
|
||||||
|
const AskUserInputArgs = z.object({
|
||||||
|
questions: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
question: z.string(),
|
||||||
|
type: z.enum(['single_select', 'multi_select']),
|
||||||
|
options: z.array(z.string()).min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(3),
|
||||||
|
});
|
||||||
|
|
||||||
interface MessageHandlers {
|
interface MessageHandlers {
|
||||||
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
|
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
|
||||||
enqueueCompact: (sessionId: string, chatId: string, compactMessageId: string, user: string) => void;
|
enqueueCompact: (sessionId: string, chatId: string, compactMessageId: string, user: string) => void;
|
||||||
@@ -24,6 +57,13 @@ interface MessageHandlers {
|
|||||||
content: string
|
content: string
|
||||||
) => void;
|
) => void;
|
||||||
publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
|
publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
|
||||||
|
// Batch 9.7: lets the answer endpoint emit the tool_result frame that the
|
||||||
|
// pause path intentionally skipped. Matches SkillInvokeHandlers in
|
||||||
|
// routes/skills.ts so index.ts can pass the same broker.publish adapter.
|
||||||
|
publishSessionFrame: (
|
||||||
|
sessionId: string,
|
||||||
|
frame: Record<string, unknown> & { type: string }
|
||||||
|
) => void;
|
||||||
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
|
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||||
hasActiveInference: (chatId: string) => boolean;
|
hasActiveInference: (chatId: string) => boolean;
|
||||||
}
|
}
|
||||||
@@ -389,4 +429,169 @@ export function registerMessageRoutes(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Batch 9.7: resume an ask_user_input pause. Validates the body matches the
|
||||||
|
// question shape the model declared, UPDATEs the pending tool row's
|
||||||
|
// tool_results to the AnswerSet, publishes the deferred tool_result frame,
|
||||||
|
// and enqueues the next assistant turn. Error codes per spec:
|
||||||
|
// 400 invalid_body / mismatched_answer_shape
|
||||||
|
// 404 chat_not_found / unknown_tool_call_id
|
||||||
|
// 409 tool_call_already_answered
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/chats/:id/answer_user_input',
|
||||||
|
async (req, reply) => {
|
||||||
|
const parsed = AnswerUserInputBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid_body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
const { tool_call_id, answers } = parsed.data;
|
||||||
|
|
||||||
|
const chatRows = await sql<Chat[]>`
|
||||||
|
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
|
||||||
|
`;
|
||||||
|
if (chatRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'chat_not_found' };
|
||||||
|
}
|
||||||
|
const chat = chatRows[0]!;
|
||||||
|
const sessionId = chat.session_id;
|
||||||
|
|
||||||
|
// Find the assistant message that emitted this tool_call. Scoped by
|
||||||
|
// chat_id + role to avoid cross-chat lookups; ordered by created_at DESC
|
||||||
|
// because the most recent issuance wins when an LLM reuses call IDs
|
||||||
|
// across turns (the older, already-answered one is a different row with
|
||||||
|
// populated tool_results downstream).
|
||||||
|
const callerRows = await sql<{ id: string; tool_calls: ToolCall[] | null }[]>`
|
||||||
|
SELECT id, tool_calls FROM messages
|
||||||
|
WHERE chat_id = ${chat.id}
|
||||||
|
AND role = 'assistant'
|
||||||
|
AND tool_calls IS NOT NULL
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`;
|
||||||
|
let foundCall: ToolCall | null = null;
|
||||||
|
for (const row of callerRows) {
|
||||||
|
const match = row.tool_calls?.find((tc) => tc.id === tool_call_id);
|
||||||
|
if (match) {
|
||||||
|
foundCall = match;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!foundCall) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'unknown_tool_call_id' };
|
||||||
|
}
|
||||||
|
if (foundCall.name !== 'ask_user_input') {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'tool_call_not_ask_user_input' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the args themselves — the LLM could have emitted bad JSON.
|
||||||
|
const argsParsed = AskUserInputArgs.safeParse(foundCall.args);
|
||||||
|
if (!argsParsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' };
|
||||||
|
}
|
||||||
|
const questions = argsParsed.data.questions;
|
||||||
|
if (answers.length !== questions.length) {
|
||||||
|
reply.code(400);
|
||||||
|
return {
|
||||||
|
error: 'mismatched_answer_shape',
|
||||||
|
detail: `expected ${questions.length} answer(s), got ${answers.length}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
for (let i = 0; i < questions.length; i++) {
|
||||||
|
const q = questions[i]!;
|
||||||
|
const a = answers[i]!;
|
||||||
|
for (const sel of a.selected_options) {
|
||||||
|
if (!q.options.includes(sel)) {
|
||||||
|
reply.code(400);
|
||||||
|
return {
|
||||||
|
error: 'mismatched_answer_shape',
|
||||||
|
detail: `answer ${i + 1} contains option not in question: ${sel}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (q.type === 'single_select' && a.selected_options.length > 1) {
|
||||||
|
reply.code(400);
|
||||||
|
return {
|
||||||
|
error: 'mismatched_answer_shape',
|
||||||
|
detail: `answer ${i + 1} has multiple selections on single_select`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const hasOpt = a.selected_options.length > 0;
|
||||||
|
const hasText = a.free_text !== null && a.free_text.trim().length > 0;
|
||||||
|
if (!hasOpt && !hasText) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} is empty` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the pending tool row. ORDER BY created_at DESC + LIMIT 1 picks
|
||||||
|
// the most recent row with this tool_call_id; the already-answered
|
||||||
|
// check below guards against UPDATE-ing a stale answer.
|
||||||
|
const toolRows = await sql<{
|
||||||
|
id: string;
|
||||||
|
tool_results: { tool_call_id: string; output: unknown } | null;
|
||||||
|
}[]>`
|
||||||
|
SELECT id, tool_results FROM messages
|
||||||
|
WHERE chat_id = ${chat.id}
|
||||||
|
AND role = 'tool'
|
||||||
|
AND tool_results->>'tool_call_id' = ${tool_call_id}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const toolRow = toolRows[0];
|
||||||
|
if (!toolRow) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
|
||||||
|
}
|
||||||
|
if (toolRow.tool_results && toolRow.tool_results.output !== null) {
|
||||||
|
reply.code(409);
|
||||||
|
return { error: 'tool_call_already_answered' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const answerSet = { answers };
|
||||||
|
const newToolResults = {
|
||||||
|
tool_call_id,
|
||||||
|
output: answerSet,
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await sql.begin(async (tx) => {
|
||||||
|
await tx`
|
||||||
|
UPDATE messages
|
||||||
|
SET tool_results = ${tx.json(newToolResults as never)}
|
||||||
|
WHERE id = ${toolRow.id}
|
||||||
|
`;
|
||||||
|
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||||
|
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
|
||||||
|
return {
|
||||||
|
tool_message_id: toolRow.id,
|
||||||
|
assistant_message_id: assistantMsg!.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish the deferred tool_result frame. useSessionStream's reducer
|
||||||
|
// updates the matching tool_run.result so AskUserInputCard flips into
|
||||||
|
// its read-only "answered" mode without a refetch.
|
||||||
|
handlers.publishSessionFrame(sessionId, {
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_message_id: result.tool_message_id,
|
||||||
|
tool_call_id,
|
||||||
|
chat_id: chat.id,
|
||||||
|
output: answerSet,
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
|
||||||
|
|
||||||
|
reply.code(202);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ const CACHE_TTL_MS = 60_000;
|
|||||||
// explicit `tools:` field inherit the full default set (which now includes
|
// explicit `tools:` field inherit the full default set (which now includes
|
||||||
// the skill tools); agents with an explicit `tools:` array must list any
|
// the skill tools); agents with an explicit `tools:` array must list any
|
||||||
// skill tool they want to use — strict opt-in.
|
// skill tool they want to use — strict opt-in.
|
||||||
|
// Batch 9.7: ask_user_input added — same opt-in semantics. Agents with an
|
||||||
|
// explicit tools list that omits it cannot trigger the interactive picker.
|
||||||
const ALL_TOOL_NAMES = [
|
const ALL_TOOL_NAMES = [
|
||||||
'view_file', 'list_dir', 'grep', 'find_files', 'git_status',
|
'view_file', 'list_dir', 'grep', 'find_files', 'git_status',
|
||||||
'skill_find', 'skill_use', 'skill_resource',
|
'skill_find', 'skill_use', 'skill_resource',
|
||||||
|
'ask_user_input',
|
||||||
] as const;
|
] as const;
|
||||||
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
|
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
|
||||||
const DEFAULT_TEMPERATURE = 0.7;
|
const DEFAULT_TEMPERATURE = 0.7;
|
||||||
|
|||||||
@@ -665,6 +665,12 @@ async function executeToolPhase(
|
|||||||
model: session.model,
|
model: session.model,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Batch 9.7: ask_user_input pauses the loop. The tool row is still inserted
|
||||||
|
// (the answer endpoint needs a target row to UPDATE), but tool_results is
|
||||||
|
// pre-stamped with output=null as a "pending" sentinel and no tool_result
|
||||||
|
// frame goes out — the card renders from the tool_call frame alone. Mixed
|
||||||
|
// batches still execute the other tools normally.
|
||||||
|
let pausingForUserInput = false;
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
toolCalls.map(async (tc) => {
|
toolCalls.map(async (tc) => {
|
||||||
const [toolRow] = await ctx.sql<{ id: string }[]>`
|
const [toolRow] = await ctx.sql<{ id: string }[]>`
|
||||||
@@ -673,6 +679,16 @@ async function executeToolPhase(
|
|||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
const toolMessageId = toolRow!.id;
|
const toolMessageId = toolRow!.id;
|
||||||
|
if (tc.name === 'ask_user_input') {
|
||||||
|
pausingForUserInput = true;
|
||||||
|
const sentinel = { tool_call_id: tc.id, output: null, truncated: false };
|
||||||
|
await ctx.sql`
|
||||||
|
UPDATE messages
|
||||||
|
SET tool_results = ${ctx.sql.json(sentinel as never)}
|
||||||
|
WHERE id = ${toolMessageId}
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const tres = await executeToolCall(projectRoot, tc);
|
const tres = await executeToolCall(projectRoot, tc);
|
||||||
const stored = {
|
const stored = {
|
||||||
tool_call_id: tc.id,
|
tool_call_id: tc.id,
|
||||||
@@ -697,6 +713,23 @@ async function executeToolPhase(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (pausingForUserInput) {
|
||||||
|
// Drop the dot back to idle — the card is the actionable surface now.
|
||||||
|
// The next inference turn fires from POST /api/chats/:id/answer_user_input
|
||||||
|
// once the user submits their answers.
|
||||||
|
ctx.publishUser({
|
||||||
|
type: 'chat_status',
|
||||||
|
chat_id: chatId,
|
||||||
|
status: 'idle',
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
ctx.log.info(
|
||||||
|
{ sessionId, chatId, assistantMessageId },
|
||||||
|
'inference paused awaiting user input',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const [nextAssistant] = await ctx.sql<{ id: string }[]>`
|
const [nextAssistant] = await ctx.sql<{ id: string }[]>`
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||||
|
|||||||
@@ -405,6 +405,81 @@ export const skillResource: ToolDef<SkillResourceInputT> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Batch 9.7: ask_user_input. Interactive elicitation. The model emits a tool
|
||||||
|
// call with 1-3 structured questions; the inference loop PAUSES (does not
|
||||||
|
// execute the tool server-side, does not recurse) and waits for the frontend
|
||||||
|
// to POST /api/chats/:id/answer_user_input with the user's selections. See
|
||||||
|
// routes/messages.ts for the resume path and services/inference.ts for the
|
||||||
|
// pause branch in executeToolPhase.
|
||||||
|
const AskUserInputInput = z.object({
|
||||||
|
questions: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
question: z.string().min(1).max(200),
|
||||||
|
type: z.enum(['single_select', 'multi_select']),
|
||||||
|
options: z.array(z.string().min(1).max(80)).min(2).max(6),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(3),
|
||||||
|
});
|
||||||
|
type AskUserInputInputT = z.infer<typeof AskUserInputInput>;
|
||||||
|
|
||||||
|
export const askUserInput: ToolDef<AskUserInputInputT> = {
|
||||||
|
name: 'ask_user_input',
|
||||||
|
description:
|
||||||
|
"Ask the user 1-3 structured questions through an inline picker UI. Use when you genuinely need a choice the user must make (e.g. scope, options, preferences) before continuing. Each question has 2-6 options and accepts free-text answers in addition. The tool call pauses the conversation until the user submits — the next assistant turn sees their answers as the tool result. Do not use for trivial yes/no clarifications you could infer; prefer it over multi-paragraph speculation about what the user might want.",
|
||||||
|
inputSchema: AskUserInputInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'ask_user_input',
|
||||||
|
description:
|
||||||
|
'Ask the user 1-3 structured questions through an inline picker. Pauses the conversation until the user answers; the next turn sees their selections.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
questions: {
|
||||||
|
type: 'array',
|
||||||
|
minItems: 1,
|
||||||
|
maxItems: 3,
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
question: { type: 'string', description: '<=200 chars, shown to the user' },
|
||||||
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['single_select', 'multi_select'],
|
||||||
|
description: 'single_select = at most one option; multi_select = any subset',
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: 'array',
|
||||||
|
minItems: 2,
|
||||||
|
maxItems: 6,
|
||||||
|
items: { type: 'string' },
|
||||||
|
description: '2-6 strings, each <=80 chars; free-text input is always available alongside',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['question', 'type', 'options'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['questions'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Server-side no-op. The "execution" of ask_user_input is the user's
|
||||||
|
// response, captured client-side and posted to /api/chats/:id/answer_user_input.
|
||||||
|
// The inference loop detects this tool by name and pauses before reaching
|
||||||
|
// executeToolCall — this fallback only runs if something bypasses that
|
||||||
|
// branch, in which case the pending sentinel matches the pause-path shape.
|
||||||
|
async execute(input) {
|
||||||
|
return { _pending: true, questions: input.questions };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
||||||
viewFile as ToolDef<unknown>,
|
viewFile as ToolDef<unknown>,
|
||||||
listDir as ToolDef<unknown>,
|
listDir as ToolDef<unknown>,
|
||||||
@@ -414,6 +489,7 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
|||||||
skillFind as ToolDef<unknown>,
|
skillFind as ToolDef<unknown>,
|
||||||
skillUse as ToolDef<unknown>,
|
skillUse as ToolDef<unknown>,
|
||||||
skillResource as ToolDef<unknown>,
|
skillResource as ToolDef<unknown>,
|
||||||
|
askUserInput as ToolDef<unknown>,
|
||||||
];
|
];
|
||||||
|
|
||||||
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
||||||
@@ -422,6 +498,8 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
|||||||
// default (10). Every tool in v1.8.2 happens to be read-only, so the
|
// default (10). Every tool in v1.8.2 happens to be read-only, so the
|
||||||
// non-RO branch only takes effect once BooCoder lands write tools.
|
// non-RO branch only takes effect once BooCoder lands write tools.
|
||||||
// Batch 9.6: skill_* added; all still read-only.
|
// Batch 9.6: skill_* added; all still read-only.
|
||||||
|
// Batch 9.7: ask_user_input added — it pauses execution but doesn't mutate
|
||||||
|
// project state, so it belongs in the read-only set for budget purposes.
|
||||||
export const READ_ONLY_TOOL_NAMES = [
|
export const READ_ONLY_TOOL_NAMES = [
|
||||||
'view_file',
|
'view_file',
|
||||||
'list_dir',
|
'list_dir',
|
||||||
@@ -431,6 +509,7 @@ export const READ_ONLY_TOOL_NAMES = [
|
|||||||
'skill_find',
|
'skill_find',
|
||||||
'skill_use',
|
'skill_use',
|
||||||
'skill_resource',
|
'skill_resource',
|
||||||
|
'ask_user_input',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
AgentsResponse,
|
AgentsResponse,
|
||||||
GitMeta,
|
GitMeta,
|
||||||
Skill,
|
Skill,
|
||||||
|
AskUserAnswer,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -202,6 +203,17 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ skill_name: skillName, user_message: userMessage }),
|
body: JSON.stringify({ skill_name: skillName, user_message: userMessage }),
|
||||||
}),
|
}),
|
||||||
|
// Batch 9.7: submit answers for a paused ask_user_input call. Server
|
||||||
|
// validates against the question shape, UPDATEs the pending tool row,
|
||||||
|
// publishes the deferred tool_result frame, and enqueues the next turn.
|
||||||
|
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: {
|
messages: {
|
||||||
|
|||||||
@@ -241,6 +241,27 @@ export interface Skill {
|
|||||||
mtime: number;
|
mtime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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[];
|
||||||
|
}
|
||||||
|
|
||||||
// v1.9: 'settings' is an ephemeral pane kind — never persisted, always
|
// v1.9: 'settings' is an ephemeral pane kind — never persisted, always
|
||||||
// singleton per workspace. The pane hook filters it out before writing to
|
// singleton per workspace. The pane hook filters it out before writing to
|
||||||
// localStorage and dedupes on insertion via toggleSettingsPane().
|
// localStorage and dedupes on insertion via toggleSettingsPane().
|
||||||
|
|||||||
324
apps/web/src/components/AskUserInputCard.tsx
Normal file
324
apps/web/src/components/AskUserInputCard.tsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
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 (
|
||||||
|
<div className="rounded border border-destructive/40 bg-destructive/10 text-xs px-3 py-2 text-destructive">
|
||||||
|
ask_user_input: malformed tool args
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 <AnsweredView questions={questions} answers={answerSet} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PendingView questions={questions} toolCallId={toolCall.id} chatId={chatId} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string[][]>(() => questions.map(() => []));
|
||||||
|
const [freeTexts, setFreeTexts] = useState<string[]>(() => 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) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'submit failed');
|
||||||
|
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 (
|
||||||
|
<div className="rounded-lg border bg-muted/20 text-sm">
|
||||||
|
<div className="px-4 py-3 space-y-4">
|
||||||
|
{questions.map((q, i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
{questions.length > 1 && (
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||||
|
Question {i + 1}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="font-medium leading-snug">{q.question}</div>
|
||||||
|
{q.type === 'single_select' ? (
|
||||||
|
<RadioGroup
|
||||||
|
value={selections[i]![0] ?? ''}
|
||||||
|
onValueChange={(v) => pickSingle(i, v)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{q.options.map((opt, j) => {
|
||||||
|
const id = `q${i}-opt${j}`;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={j}
|
||||||
|
htmlFor={id}
|
||||||
|
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
|
||||||
|
<span>{opt}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</RadioGroup>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{q.options.map((opt, j) => {
|
||||||
|
const id = `q${i}-opt${j}`;
|
||||||
|
const checked = selections[i]!.includes(opt);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={j}
|
||||||
|
htmlFor={id}
|
||||||
|
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={submitting}
|
||||||
|
onChange={() => toggleMulti(i, opt)}
|
||||||
|
className="mt-1 size-3.5 rounded border-input accent-primary"
|
||||||
|
/>
|
||||||
|
<span>{opt}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pt-1 space-y-1">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||||
|
Or type a custom answer
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={freeTexts[i]}
|
||||||
|
disabled={submitting}
|
||||||
|
placeholder="Free text…"
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{showSubmitButton && (
|
||||||
|
<div className="flex justify-end gap-2 border-t px-4 py-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={!allComplete || submitting}
|
||||||
|
onClick={() => void submit(buildAnswers())}
|
||||||
|
>
|
||||||
|
{submitting ? 'Submitting…' : 'Submit'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnsweredView({
|
||||||
|
questions,
|
||||||
|
answers,
|
||||||
|
}: {
|
||||||
|
questions: AskUserQuestion[];
|
||||||
|
answers: AskUserAnswerSet | null;
|
||||||
|
}) {
|
||||||
|
if (!answers) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-muted/20 text-xs px-4 py-3 text-muted-foreground">
|
||||||
|
ask_user_input: answers unavailable
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-muted/10 text-sm">
|
||||||
|
<div className="px-4 py-3 space-y-3">
|
||||||
|
{questions.map((q, i) => {
|
||||||
|
const a = answers.answers[i];
|
||||||
|
if (!a) return null;
|
||||||
|
return (
|
||||||
|
<div key={i} className="space-y-1.5">
|
||||||
|
{questions.length > 1 && (
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||||
|
Question {i + 1}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="font-medium leading-snug">{q.question}</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{q.options.map((opt, j) => {
|
||||||
|
const selected = a.selected_options.includes(opt);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={j}
|
||||||
|
className={
|
||||||
|
selected
|
||||||
|
? 'flex items-start gap-2 text-sm leading-snug text-foreground'
|
||||||
|
: 'flex items-start gap-2 text-sm leading-snug text-muted-foreground/60 line-through'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 size-3.5 shrink-0 inline-flex items-center justify-center">
|
||||||
|
{selected && <Check className="size-3 text-primary" />}
|
||||||
|
</span>
|
||||||
|
<span>{opt}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{a.free_text && (
|
||||||
|
<div className="rounded bg-background border px-2 py-1 text-xs font-mono whitespace-pre-wrap">
|
||||||
|
{a.free_text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { Chat, Message } from '@/api/types';
|
|||||||
import { MessageBubble } from './MessageBubble';
|
import { MessageBubble } from './MessageBubble';
|
||||||
import { ToolCallGroup } from './ToolCallGroup';
|
import { ToolCallGroup } from './ToolCallGroup';
|
||||||
import { ToolCallLine, type ToolRun } from './ToolCallLine';
|
import { ToolCallLine, type ToolRun } from './ToolCallLine';
|
||||||
|
import { AskUserInputCard } from './AskUserInputCard';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
@@ -12,9 +13,11 @@ interface Props {
|
|||||||
// v1.8.2: pre-render units. The single linear `messages` array gets walked
|
// v1.8.2: pre-render units. The single linear `messages` array gets walked
|
||||||
// into a render-time list where each tool_call is a first-class item and
|
// into a render-time list where each tool_call is a first-class item and
|
||||||
// tool_result messages are folded onto their matching tool_run by id.
|
// tool_result messages are folded onto their matching tool_run by id.
|
||||||
|
// Batch 9.7: tool_run carries chat_id so AskUserInputCard can post the
|
||||||
|
// answer without threading the chat id through MessageList's parent.
|
||||||
type RenderItem =
|
type RenderItem =
|
||||||
| { kind: 'message'; message: Message; capHitInfo?: { position: number; isLatest: boolean } }
|
| { kind: 'message'; message: Message; capHitInfo?: { position: number; isLatest: boolean } }
|
||||||
| { kind: 'tool_run'; run: ToolRun; key: string }
|
| { kind: 'tool_run'; run: ToolRun; key: string; chatId: string }
|
||||||
| { kind: 'tool_group'; runs: ToolRun[]; key: string };
|
| { kind: 'tool_group'; runs: ToolRun[]; key: string };
|
||||||
|
|
||||||
const GROUP_THRESHOLD = 3;
|
const GROUP_THRESHOLD = 3;
|
||||||
@@ -50,7 +53,7 @@ function flatten(messages: Message[]): RenderItem[] {
|
|||||||
for (const tc of m.tool_calls!) {
|
for (const tc of m.tool_calls!) {
|
||||||
const run: ToolRun = { call: tc, result: null };
|
const run: ToolRun = { call: tc, result: null };
|
||||||
runsByCallId.set(tc.id, run);
|
runsByCallId.set(tc.id, run);
|
||||||
items.push({ kind: 'tool_run', run, key: tc.id });
|
items.push({ kind: 'tool_run', run, key: tc.id, chatId: m.chat_id });
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -63,6 +66,9 @@ function flatten(messages: Message[]): RenderItem[] {
|
|||||||
// Second pass: collapse runs of >=GROUP_THRESHOLD consecutive tool_run items
|
// Second pass: collapse runs of >=GROUP_THRESHOLD consecutive tool_run items
|
||||||
// of the same tool name into a single tool_group. Any other render item
|
// of the same tool name into a single tool_group. Any other render item
|
||||||
// (text bubble, sentinel, user message) breaks the chain.
|
// (text bubble, sentinel, user message) breaks the chain.
|
||||||
|
// Batch 9.7: ask_user_input never groups — each pause has its own card so
|
||||||
|
// grouping would render them as collapsed ToolCallLines which can't surface
|
||||||
|
// the interactive form.
|
||||||
function group(items: RenderItem[]): RenderItem[] {
|
function group(items: RenderItem[]): RenderItem[] {
|
||||||
const out: RenderItem[] = [];
|
const out: RenderItem[] = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -74,6 +80,11 @@ function group(items: RenderItem[]): RenderItem[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const name = item.run.call.name;
|
const name = item.run.call.name;
|
||||||
|
if (name === 'ask_user_input') {
|
||||||
|
out.push(item);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let j = i + 1;
|
let j = i + 1;
|
||||||
while (
|
while (
|
||||||
j < items.length &&
|
j < items.length &&
|
||||||
@@ -82,7 +93,12 @@ function group(items: RenderItem[]): RenderItem[] {
|
|||||||
) {
|
) {
|
||||||
j += 1;
|
j += 1;
|
||||||
}
|
}
|
||||||
const run = items.slice(i, j) as Array<{ kind: 'tool_run'; run: ToolRun; key: string }>;
|
const run = items.slice(i, j) as Array<{
|
||||||
|
kind: 'tool_run';
|
||||||
|
run: ToolRun;
|
||||||
|
key: string;
|
||||||
|
chatId: string;
|
||||||
|
}>;
|
||||||
if (run.length >= GROUP_THRESHOLD) {
|
if (run.length >= GROUP_THRESHOLD) {
|
||||||
out.push({
|
out.push({
|
||||||
kind: 'tool_group',
|
kind: 'tool_group',
|
||||||
@@ -150,6 +166,16 @@ export function MessageList({ messages, sessionChats }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (item.kind === 'tool_run') {
|
if (item.kind === 'tool_run') {
|
||||||
|
if (item.run.call.name === 'ask_user_input') {
|
||||||
|
return (
|
||||||
|
<AskUserInputCard
|
||||||
|
key={item.key}
|
||||||
|
toolCall={item.run.call}
|
||||||
|
toolResult={item.run.result}
|
||||||
|
chatId={item.chatId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return <ToolCallLine key={item.key} run={item.run} />;
|
return <ToolCallLine key={item.key} run={item.run} />;
|
||||||
}
|
}
|
||||||
return <ToolCallGroup key={item.key} runs={item.runs} />;
|
return <ToolCallGroup key={item.key} runs={item.runs} />;
|
||||||
|
|||||||
Reference in New Issue
Block a user