From d3a69edc417976e8e5d254ed106fcf29c31dc579 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:11:46 +0000 Subject: [PATCH 1/7] Initial plan From 61cdd277d550d75e5d4dbd048de0cdd536598def Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:20:47 +0000 Subject: [PATCH 2/7] feat: add metadata_assistant agent, tool confirmations, and AiChatPanel tool display - Create metadata_assistant agent with 6 metadata tools and system prompt - Add requiresConfirmation: true to create_object and delete_field tools - Enhance AiChatPanel to render tool invocation parts (calling/success/error/denied) - Implement approval-requested UI with Approve/Deny buttons using addToolApprovalResponse - Add tests for new agent, tool flags, and tool part persistence - Update CHANGELOG.md Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/8a978a0a-5a20-45f0-b841-51f0a8857945 Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com> --- CHANGELOG.md | 21 ++ apps/studio/src/components/AiChatPanel.tsx | 193 +++++++++++++++++- apps/studio/test/ai-chat-panel.test.tsx | 105 ++++++++++ .../src/__tests__/chatbot-features.test.ts | 58 ++++++ .../src/__tests__/metadata-tools.test.ts | 18 ++ .../services/service-ai/src/agents/index.ts | 1 + .../src/agents/metadata-assistant-agent.ts | 87 ++++++++ packages/services/service-ai/src/index.ts | 2 +- .../src/tools/create-object.tool.ts | 1 + .../service-ai/src/tools/delete-field.tool.ts | 1 + 10 files changed, 481 insertions(+), 6 deletions(-) create mode 100644 packages/services/service-ai/src/agents/metadata-assistant-agent.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e76955950c..f33273f1e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 absent, fixing the CI `@objectstack/client#test` failure. ### Added +- **Metadata Assistant Agent (`service-ai`)** — New `metadata_assistant` agent definition that + binds all 6 metadata management tools (`create_object`, `add_field`, `modify_field`, + `delete_field`, `list_metadata_objects`, `describe_metadata_object`). Includes a tailored + system prompt that guides the AI to use snake_case naming, verify existing schemas before + modifications, and warn about destructive operations. Configured with `react` planning + strategy (10 iterations, replan enabled) for multi-step schema design conversations. +- **Tool Confirmation Flags** — Added `requiresConfirmation: true` to `create_object` and + `delete_field` tool definitions. These destructive/creation operations now signal to the + frontend that user approval is needed before execution. +- **Frontend Tool Call Display (`AiChatPanel`)** — Enhanced the AI Chat Panel to render tool + invocation parts from the Vercel AI SDK v6 stream protocol. Displays tool call status with + visual indicators: + - **Calling**: Spinner animation with tool name and argument summary + - **Confirmation**: Yellow-bordered card with Approve/Deny buttons for `requiresConfirmation` tools + - **Success**: Green success indicator with result preview + - **Error**: Red error indicator with error message + - **Denied**: Muted indicator for user-denied operations +- **Operation Confirmation Mechanism** — Integrated the Vercel AI SDK `addToolApprovalResponse` + hook to support approval/denial workflows for tools marked with `requiresConfirmation`. + When the server sends an `approval-requested` state, the chat panel shows Approve and Deny + buttons. User decisions are sent back to the server to continue or abort the tool execution. - **Metadata Management Tools (`service-ai`)** — Added 6 built-in AI tools for metadata CRUD operations, each defined as a first-class `Tool` metadata file using `defineTool()` from `@objectstack/spec/ai`: diff --git a/apps/studio/src/components/AiChatPanel.tsx b/apps/studio/src/components/AiChatPanel.tsx index dadc603795..e1e3bc962c 100644 --- a/apps/studio/src/components/AiChatPanel.tsx +++ b/apps/studio/src/components/AiChatPanel.tsx @@ -4,7 +4,10 @@ import { useState, useRef, useEffect, useMemo } from 'react'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; import type { UIMessage } from 'ai'; -import { Bot, X, Send, Trash2, Sparkles } from 'lucide-react'; +import { + Bot, X, Send, Trash2, Sparkles, + Wrench, CheckCircle2, XCircle, Loader2, ShieldAlert, +} from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; @@ -25,6 +28,168 @@ function getMessageText(msg: UIMessage): string { .join(''); } +/** + * Convert a snake_case tool name to a human-readable label. + */ +function formatToolName(name: string): string { + return name + .replace(/_/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** + * Render a concise summary of tool input arguments. + */ +function formatToolArgs(input: unknown): string { + if (!input || typeof input !== 'object') return ''; + const entries = Object.entries(input as Record); + if (entries.length === 0) return ''; + return entries + .slice(0, 4) + .map(([k, v]) => { + const val = typeof v === 'string' ? v : JSON.stringify(v); + const display = typeof val === 'string' && val.length > 30 ? val.slice(0, 30) + '…' : val; + return `${k}: ${display}`; + }) + .join(', '); +} + +/** + * Type guard to check if a message part is a tool invocation (dynamic-tool). + */ +function isToolPart(part: UIMessage['parts'][number]): part is Extract { + return part.type === 'dynamic-tool'; +} + +// ── Tool Invocation State Labels ──────────────────────────────────── + +interface ToolInvocationDisplayProps { + part: Extract; + onApprove?: (approvalId: string) => void; + onDeny?: (approvalId: string) => void; +} + +/** + * Renders a single tool invocation part with appropriate status indicator. + */ +function ToolInvocationDisplay({ part, onApprove, onDeny }: ToolInvocationDisplayProps) { + const toolLabel = formatToolName(part.toolName); + const argsText = formatToolArgs(part.input); + + switch (part.state) { + case 'input-streaming': + case 'input-available': + return ( +
+ +
+ Calling {toolLabel} + {argsText && ( +

{argsText}

+ )} +
+
+ ); + + case 'approval-requested': + return ( +
+
+ +
+ Confirm: {toolLabel} + {argsText && ( +

{argsText}

+ )} +
+
+ {part.approval && onApprove && onDeny && ( +
+ + +
+ )} +
+ ); + + case 'output-available': + return ( +
+ +
+ {toolLabel} +

+ {typeof part.output === 'string' + ? part.output.length > 80 ? part.output.slice(0, 80) + '…' : part.output + : JSON.stringify(part.output).slice(0, 80)} +

+
+
+ ); + + case 'output-error': + return ( +
+ +
+ {toolLabel} failed +

{part.errorText}

+
+
+ ); + + case 'output-denied': + return ( +
+ + {toolLabel} — denied +
+ ); + + default: + return ( +
+ + {toolLabel} +
+ ); + } +} + export function AiChatPanel() { const { isOpen, setOpen, toggle } = useAiChatPanel(); const [input, setInput] = useState(''); @@ -39,7 +204,7 @@ export function AiChatPanel() { [baseUrl], ); - const { messages, sendMessage, setMessages, status, error } = useChat({ + const { messages, sendMessage, setMessages, status, error, addToolApprovalResponse } = useChat({ transport, messages: initialMessages, }); @@ -167,12 +332,14 @@ export function AiChatPanel() { )} {messages.map((msg) => { const text = getMessageText(msg); - if (!text && msg.role !== 'user') return null; + const toolParts = (msg.parts ?? []).filter(isToolPart); + const hasContent = !!text || toolParts.length > 0; + if (!hasContent && msg.role !== 'user') return null; return (
{msg.role === 'user' ? 'You' : 'Assistant'} -
{text}
+ {text &&
{text}
} + {toolParts.map((toolPart) => ( + + addToolApprovalResponse({ id: approvalId, approved: true }) + } + onDeny={(approvalId) => + addToolApprovalResponse({ + id: approvalId, + approved: false, + reason: 'User denied the operation', + }) + } + /> + ))}
); })} diff --git a/apps/studio/test/ai-chat-panel.test.tsx b/apps/studio/test/ai-chat-panel.test.tsx index 93691c9223..8b7460b022 100644 --- a/apps/studio/test/ai-chat-panel.test.tsx +++ b/apps/studio/test/ai-chat-panel.test.tsx @@ -16,6 +16,39 @@ function makeMsg(overrides: { id: string; role: 'user' | 'assistant'; content: s }; } +/** + * Create a UIMessage that includes tool invocation parts for testing. + */ +function makeMsgWithToolParts(overrides: { + id: string; + role: 'user' | 'assistant'; + text?: string; + toolParts?: Array<{ toolName: string; toolCallId: string; state: string; input?: unknown; output?: unknown; errorText?: string }>; +}): UIMessage { + const parts: UIMessage['parts'] = []; + if (overrides.text) { + parts.push({ type: 'text' as const, text: overrides.text }); + } + if (overrides.toolParts) { + for (const tp of overrides.toolParts) { + parts.push({ + type: 'dynamic-tool', + toolName: tp.toolName, + toolCallId: tp.toolCallId, + state: tp.state, + input: tp.input, + output: tp.output, + errorText: tp.errorText, + } as unknown as UIMessage['parts'][number]); + } + } + return { + id: overrides.id, + role: overrides.role, + parts, + }; +} + describe('use-ai-chat-panel', () => { beforeEach(() => { localStorage.clear(); @@ -94,3 +127,75 @@ describe('AiChatPanel constants', () => { expect(localStorage.getItem('objectstack:ai-chat-messages')).toBeTruthy(); }); }); + +// ═══════════════════════════════════════════════════════════════════ +// Tool Invocation Message Parts +// ═══════════════════════════════════════════════════════════════════ + +describe('Messages with tool invocation parts', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('should persist and restore messages containing tool invocation parts', () => { + const msg = makeMsgWithToolParts({ + id: 'a1', + role: 'assistant', + text: 'Creating object...', + toolParts: [ + { + toolName: 'create_object', + toolCallId: 'tc_1', + state: 'output-available', + input: { name: 'project', label: 'Project' }, + output: { name: 'project', label: 'Project', fieldCount: 0 }, + }, + ], + }); + saveMessages([msg]); + const restored = loadMessages(); + expect(restored).toHaveLength(1); + expect(restored[0].parts).toHaveLength(2); // text + tool part + }); + + it('should handle messages with only tool parts (no text)', () => { + const msg = makeMsgWithToolParts({ + id: 'a2', + role: 'assistant', + toolParts: [ + { + toolName: 'list_metadata_objects', + toolCallId: 'tc_2', + state: 'output-available', + input: {}, + output: { objects: [], totalCount: 0 }, + }, + ], + }); + saveMessages([msg]); + const restored = loadMessages(); + expect(restored).toHaveLength(1); + expect(restored[0].parts).toHaveLength(1); // only tool part + }); + + it('should persist tool error parts', () => { + const msg = makeMsgWithToolParts({ + id: 'a3', + role: 'assistant', + toolParts: [ + { + toolName: 'create_object', + toolCallId: 'tc_3', + state: 'output-error', + input: { name: 'Bad Name' }, + errorText: 'Invalid object name "Bad Name". Must be snake_case.', + }, + ], + }); + saveMessages([msg]); + const restored = loadMessages(); + expect(restored).toHaveLength(1); + const toolPart = restored[0].parts.find((p: { type: string }) => p.type === 'dynamic-tool'); + expect(toolPart).toBeDefined(); + }); +}); diff --git a/packages/services/service-ai/src/__tests__/chatbot-features.test.ts b/packages/services/service-ai/src/__tests__/chatbot-features.test.ts index 1dceded3da..6583c4da6d 100644 --- a/packages/services/service-ai/src/__tests__/chatbot-features.test.ts +++ b/packages/services/service-ai/src/__tests__/chatbot-features.test.ts @@ -19,6 +19,7 @@ import { AgentRuntime } from '../agent-runtime.js'; import type { AgentChatContext } from '../agent-runtime.js'; import { buildAgentRoutes } from '../routes/agent-routes.js'; import { DATA_CHAT_AGENT } from '../agents/data-chat-agent.js'; +import { METADATA_ASSISTANT_AGENT } from '../agents/metadata-assistant-agent.js'; // ── Helpers ──────────────────────────────────────────────────────── @@ -843,3 +844,60 @@ describe('DATA_CHAT_AGENT', () => { expect(DATA_CHAT_AGENT.model!.temperature).toBeLessThanOrEqual(0.5); // low temp for data queries }); }); + +// ═══════════════════════════════════════════════════════════════════ +// Metadata Assistant Agent Spec +// ═══════════════════════════════════════════════════════════════════ + +describe('METADATA_ASSISTANT_AGENT', () => { + it('should be a valid agent definition', () => { + expect(METADATA_ASSISTANT_AGENT.name).toBe('metadata_assistant'); + expect(METADATA_ASSISTANT_AGENT.label).toBe('Metadata Assistant'); + expect(METADATA_ASSISTANT_AGENT.role).toBe('Schema Architect'); + expect(METADATA_ASSISTANT_AGENT.active).toBe(true); + expect(METADATA_ASSISTANT_AGENT.visibility).toBe('global'); + }); + + it('should reference all 6 metadata tools', () => { + expect(METADATA_ASSISTANT_AGENT.tools).toHaveLength(6); + const toolNames = METADATA_ASSISTANT_AGENT.tools!.map(t => t.name); + expect(toolNames).toContain('create_object'); + expect(toolNames).toContain('add_field'); + expect(toolNames).toContain('modify_field'); + expect(toolNames).toContain('delete_field'); + expect(toolNames).toContain('list_metadata_objects'); + expect(toolNames).toContain('describe_metadata_object'); + }); + + it('should use action type for mutation tools and query type for read tools', () => { + const tools = METADATA_ASSISTANT_AGENT.tools!; + const actionTools = tools.filter(t => t.type === 'action'); + const queryTools = tools.filter(t => t.type === 'query'); + expect(actionTools).toHaveLength(4); // create, add, modify, delete + expect(queryTools).toHaveLength(2); // list, describe + }); + + it('should have guardrails configured', () => { + expect(METADATA_ASSISTANT_AGENT.guardrails).toBeDefined(); + expect(METADATA_ASSISTANT_AGENT.guardrails!.maxTokensPerInvocation).toBeGreaterThan(0); + expect(METADATA_ASSISTANT_AGENT.guardrails!.blockedTopics).toBeDefined(); + }); + + it('should have model config with low temperature for schema ops', () => { + expect(METADATA_ASSISTANT_AGENT.model).toBeDefined(); + expect(METADATA_ASSISTANT_AGENT.model!.temperature).toBeLessThanOrEqual(0.5); + }); + + it('should allow higher maxIterations for multi-step schema changes', () => { + expect(METADATA_ASSISTANT_AGENT.planning).toBeDefined(); + expect(METADATA_ASSISTANT_AGENT.planning!.maxIterations).toBeGreaterThanOrEqual(10); + expect(METADATA_ASSISTANT_AGENT.planning!.allowReplan).toBe(true); + }); + + it('should have instructions mentioning metadata management capabilities', () => { + const instructions = METADATA_ASSISTANT_AGENT.instructions; + expect(instructions).toContain('snake_case'); + expect(instructions).toContain('list_metadata_objects'); + expect(instructions).toContain('describe_metadata_object'); + }); +}); diff --git a/packages/services/service-ai/src/__tests__/metadata-tools.test.ts b/packages/services/service-ai/src/__tests__/metadata-tools.test.ts index c9d4dd4c97..7c007f4b4e 100644 --- a/packages/services/service-ai/src/__tests__/metadata-tools.test.ts +++ b/packages/services/service-ai/src/__tests__/metadata-tools.test.ts @@ -122,6 +122,24 @@ describe('Individual Tool Metadata (.tool.ts)', () => { }); }); } + + it('should mark create_object as requiresConfirmation', () => { + expect(createObjectTool.requiresConfirmation).toBe(true); + }); + + it('should mark delete_field as requiresConfirmation', () => { + expect(deleteFieldTool.requiresConfirmation).toBe(true); + }); + + it('should not mark read-only tools as requiresConfirmation', () => { + expect(listMetadataObjectsTool.requiresConfirmation).toBe(false); + expect(describeMetadataObjectTool.requiresConfirmation).toBe(false); + }); + + it('should not mark add_field and modify_field as requiresConfirmation', () => { + expect(addFieldTool.requiresConfirmation).toBe(false); + expect(modifyFieldTool.requiresConfirmation).toBe(false); + }); }); // ═══════════════════════════════════════════════════════════════════ diff --git a/packages/services/service-ai/src/agents/index.ts b/packages/services/service-ai/src/agents/index.ts index 79974d447f..90b00507d6 100644 --- a/packages/services/service-ai/src/agents/index.ts +++ b/packages/services/service-ai/src/agents/index.ts @@ -1,3 +1,4 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. export { DATA_CHAT_AGENT } from './data-chat-agent.js'; +export { METADATA_ASSISTANT_AGENT } from './metadata-assistant-agent.js'; diff --git a/packages/services/service-ai/src/agents/metadata-assistant-agent.ts b/packages/services/service-ai/src/agents/metadata-assistant-agent.ts new file mode 100644 index 0000000000..ebe2a5862b --- /dev/null +++ b/packages/services/service-ai/src/agents/metadata-assistant-agent.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Agent } from '@objectstack/spec'; + +/** + * Built-in `metadata_assistant` agent definition. + * + * This agent powers AI-driven metadata management — users can create objects, + * add/modify/delete fields, and inspect schema definitions through natural + * language conversation. + * + * It is registered automatically by the AI service plugin alongside the + * `data_chat` agent when the metadata service is available. + * + * @example + * ``` + * POST /api/v1/ai/agents/metadata_assistant/chat + * { + * "messages": [{ "role": "user", "content": "Create a contracts table with name, value, and status fields" }], + * "context": {} + * } + * ``` + */ +export const METADATA_ASSISTANT_AGENT: Agent = { + name: 'metadata_assistant', + label: 'Metadata Assistant', + role: 'Schema Architect', + instructions: `You are an expert metadata architect that helps users design and manage their data models through natural language. + +Capabilities: +- Create new data objects (tables) with fields +- Add fields (columns) to existing objects +- Modify field properties (label, type, required, default value) +- Delete fields from objects +- List all registered metadata objects and their schemas +- Describe the full schema of a specific object + +Guidelines: +1. Before creating a new object, use list_metadata_objects to check if a similar one already exists. +2. Before modifying or deleting fields, use describe_metadata_object to understand the current schema. +3. Always use snake_case for object names and field names (e.g. project_task, due_date). +4. Suggest meaningful field types based on the user's description (e.g. "deadline" → date, "active" → boolean). +5. When creating objects, propose a reasonable set of initial fields based on the entity type. +6. Explain what changes you are about to make before executing them. +7. After making changes, confirm the result by describing the updated schema. +8. For destructive operations (deleting fields), always warn the user about potential data loss. +9. Always answer in the same language the user is using. +10. If the user's request is ambiguous, ask clarifying questions before proceeding.`, + + model: { + provider: 'openai', + model: 'gpt-4', + temperature: 0.2, + maxTokens: 4096, + }, + + tools: [ + { type: 'action', name: 'create_object', description: 'Create a new data object (table)' }, + { type: 'action', name: 'add_field', description: 'Add a field to an existing object' }, + { type: 'action', name: 'modify_field', description: 'Modify an existing field definition' }, + { type: 'action', name: 'delete_field', description: 'Delete a field from an object' }, + { type: 'query', name: 'list_metadata_objects', description: 'List all metadata objects' }, + { type: 'query', name: 'describe_metadata_object', description: 'Describe an object schema' }, + ], + + active: true, + visibility: 'global', + + guardrails: { + maxTokensPerInvocation: 8192, + maxExecutionTimeSec: 60, + blockedTopics: ['drop_database', 'raw_sql', 'system_tables'], + }, + + planning: { + strategy: 'react', + maxIterations: 10, + allowReplan: true, + }, + + memory: { + shortTerm: { + maxMessages: 30, + maxTokens: 8192, + }, + }, +}; diff --git a/packages/services/service-ai/src/index.ts b/packages/services/service-ai/src/index.ts index 72b2eea49d..5f83b14f04 100644 --- a/packages/services/service-ai/src/index.ts +++ b/packages/services/service-ai/src/index.ts @@ -48,7 +48,7 @@ export { AgentRuntime } from './agent-runtime.js'; export type { AgentChatContext } from './agent-runtime.js'; // Built-in agents -export { DATA_CHAT_AGENT } from './agents/index.js'; +export { DATA_CHAT_AGENT, METADATA_ASSISTANT_AGENT } from './agents/index.js'; // Object definitions export { AiConversationObject, AiMessageObject } from './objects/index.js'; diff --git a/packages/services/service-ai/src/tools/create-object.tool.ts b/packages/services/service-ai/src/tools/create-object.tool.ts index f303b191e2..fa8ad9789a 100644 --- a/packages/services/service-ai/src/tools/create-object.tool.ts +++ b/packages/services/service-ai/src/tools/create-object.tool.ts @@ -17,6 +17,7 @@ export const createObjectTool = defineTool({ 'Use this when the user wants to create a new entity, table, or data model.', category: 'data', builtIn: true, + requiresConfirmation: true, parameters: { type: 'object', properties: { diff --git a/packages/services/service-ai/src/tools/delete-field.tool.ts b/packages/services/service-ai/src/tools/delete-field.tool.ts index f28a8b3642..04d3fe598e 100644 --- a/packages/services/service-ai/src/tools/delete-field.tool.ts +++ b/packages/services/service-ai/src/tools/delete-field.tool.ts @@ -16,6 +16,7 @@ export const deleteFieldTool = defineTool({ 'Use this when the user explicitly wants to remove an attribute or column from a table.', category: 'data', builtIn: true, + requiresConfirmation: true, parameters: { type: 'object', properties: { From 319474f76f02ecd5629ccb683732aa009ec80fa9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:23:30 +0000 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20address=20code=20review=20feedb?= =?UTF-8?q?ack=20=E2=80=94=20remove=20redundant=20type=20check,=20extract?= =?UTF-8?q?=20output=20formatter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/8a978a0a-5a20-45f0-b841-51f0a8857945 Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com> --- apps/studio/src/components/AiChatPanel.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/studio/src/components/AiChatPanel.tsx b/apps/studio/src/components/AiChatPanel.tsx index e1e3bc962c..ae9e45ba85 100644 --- a/apps/studio/src/components/AiChatPanel.tsx +++ b/apps/studio/src/components/AiChatPanel.tsx @@ -48,7 +48,7 @@ function formatToolArgs(input: unknown): string { .slice(0, 4) .map(([k, v]) => { const val = typeof v === 'string' ? v : JSON.stringify(v); - const display = typeof val === 'string' && val.length > 30 ? val.slice(0, 30) + '…' : val; + const display = val.length > 30 ? val.slice(0, 30) + '…' : val; return `${k}: ${display}`; }) .join(', '); @@ -61,6 +61,14 @@ function isToolPart(part: UIMessage['parts'][number]): part is Extract maxLen ? raw.slice(0, maxLen) + '…' : raw; +} + // ── Tool Invocation State Labels ──────────────────────────────────── interface ToolInvocationDisplayProps { @@ -144,9 +152,7 @@ function ToolInvocationDisplay({ part, onApprove, onDeny }: ToolInvocationDispla
{toolLabel}

- {typeof part.output === 'string' - ? part.output.length > 80 ? part.output.slice(0, 80) + '…' : part.output - : JSON.stringify(part.output).slice(0, 80)} + {formatToolOutput(part.output)}

From e7bc0b9366ce94b9b06c5e0dd0f9afdf1b31d58d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:47:03 +0000 Subject: [PATCH 4/7] fix: register metadata_assistant agent and metadata tools in AIServicePlugin.start() Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/fead28d3-f5fd-4b74-92a2-115c25f8d39e Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/services/service-ai/src/plugin.ts | 40 +++++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/services/service-ai/src/plugin.ts b/packages/services/service-ai/src/plugin.ts index e8d073ff20..c6d05cefdb 100644 --- a/packages/services/service-ai/src/plugin.ts +++ b/packages/services/service-ai/src/plugin.ts @@ -9,8 +9,9 @@ import { buildAgentRoutes } from './routes/agent-routes.js'; import { ObjectQLConversationService } from './conversation/objectql-conversation-service.js'; import { AiConversationObject, AiMessageObject } from './objects/index.js'; import { registerDataTools } from './tools/data-tools.js'; +import { registerMetadataTools } from './tools/metadata-tools.js'; import { AgentRuntime } from './agent-runtime.js'; -import { DATA_CHAT_AGENT } from './agents/index.js'; +import { DATA_CHAT_AGENT, METADATA_ASSISTANT_AGENT } from './agents/index.js'; /** * Configuration options for the AIServicePlugin. @@ -141,10 +142,17 @@ export class AIServicePlugin implements Plugin { async start(ctx: PluginContext): Promise { if (!this.service) return; - // ── Auto-register built-in data tools if data engine + metadata are available ── + // ── Auto-register built-in tools & agents when services are available ── + let metadataService: IMetadataService | undefined; + try { + metadataService = ctx.getService('metadata'); + } catch { + // Metadata service not available — skip + } + + // Data tools require both data engine and metadata service try { const dataEngine = ctx.getService('data'); - const metadataService = ctx.getService('metadata'); if (dataEngine && metadataService) { registerDataTools(this.service.toolRegistry, { dataEngine, metadataService }); ctx.logger.info('[AI] Built-in data tools registered'); @@ -163,8 +171,30 @@ export class AIServicePlugin implements Plugin { } } } catch { - // Data engine or metadata service not available — skip data tools - ctx.logger.debug('[AI] Data engine or metadata service not available, skipping data tools'); + ctx.logger.debug('[AI] Data engine not available, skipping data tools'); + } + + // Metadata tools require only the metadata service + if (metadataService) { + try { + registerMetadataTools(this.service.toolRegistry, { metadataService }); + ctx.logger.info('[AI] Built-in metadata tools registered'); + + // Register the built-in metadata_assistant agent + const agentExists = + typeof metadataService.exists === 'function' + ? await metadataService.exists('agent', METADATA_ASSISTANT_AGENT.name) + : false; + + if (!agentExists) { + await metadataService.register('agent', METADATA_ASSISTANT_AGENT.name, METADATA_ASSISTANT_AGENT); + ctx.logger.info('[AI] metadata_assistant agent registered'); + } else { + ctx.logger.debug('[AI] metadata_assistant agent already exists, skipping auto-registration'); + } + } catch (err) { + ctx.logger.debug('[AI] Failed to register metadata tools', err instanceof Error ? err : undefined); + } } // Trigger hook to notify AI service is ready — other plugins can register tools From e310272c36401c839e6bb13a401d575088766c0b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:49:47 +0000 Subject: [PATCH 5/7] fix: add debug logging for missing metadata service catch block Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/fead28d3-f5fd-4b74-92a2-115c25f8d39e Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/services/service-ai/src/plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/service-ai/src/plugin.ts b/packages/services/service-ai/src/plugin.ts index c6d05cefdb..029dffe2b6 100644 --- a/packages/services/service-ai/src/plugin.ts +++ b/packages/services/service-ai/src/plugin.ts @@ -147,7 +147,7 @@ export class AIServicePlugin implements Plugin { try { metadataService = ctx.getService('metadata'); } catch { - // Metadata service not available — skip + ctx.logger.debug('[AI] Metadata service not available'); } // Data tools require both data engine and metadata service From ed14650d931eb84101a38e8fb232a52eb8c3cba7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:09:31 +0000 Subject: [PATCH 6/7] feat: add agent selector dropdown and GET /api/v1/ai/agents endpoint Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/cb9e5852-20ac-43d6-a26a-28a8829f5a45 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- apps/studio/src/components/AiChatPanel.tsx | 162 +++++++++++++++--- apps/studio/test/ai-chat-panel.test.tsx | 74 ++++++++ .../src/__tests__/chatbot-features.test.ts | 91 ++++++++-- .../services/service-ai/src/agent-runtime.ts | 24 +++ .../service-ai/src/routes/agent-routes.ts | 23 +++ 5 files changed, 338 insertions(+), 36 deletions(-) diff --git a/apps/studio/src/components/AiChatPanel.tsx b/apps/studio/src/components/AiChatPanel.tsx index ae9e45ba85..2ba557f0f0 100644 --- a/apps/studio/src/components/AiChatPanel.tsx +++ b/apps/studio/src/components/AiChatPanel.tsx @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { useState, useRef, useEffect, useMemo } from 'react'; +import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; import type { UIMessage } from 'ai'; @@ -10,6 +10,9 @@ import { } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; import { useAiChatPanel, loadMessages, saveMessages } from '@/hooks/use-ai-chat-panel'; @@ -18,6 +21,18 @@ import { getApiBaseUrl } from '@/lib/config'; const PANEL_WIDTH = 380; const COLLAPSED_WIDTH = 48; +/** @internal — exported for testing */ +export const AGENT_STORAGE_KEY = 'objectstack:ai-chat-agent'; +/** @internal — exported for testing */ +export const GENERAL_CHAT_VALUE = '__general__'; + +/** Summary returned by GET /api/v1/ai/agents */ +interface AgentSummary { + name: string; + label: string; + role: string; +} + /** * Extract the text content from a UIMessage's parts array. */ @@ -69,6 +84,70 @@ function formatToolOutput(output: unknown, maxLen = 80): string { return raw.length > maxLen ? raw.slice(0, maxLen) + '…' : raw; } +/** + * Build the chat API URL for the given agent selection. + * @internal — exported for testing + */ +export function chatApiUrl(baseUrl: string, agentName: string | null): string { + if (!agentName || agentName === GENERAL_CHAT_VALUE) { + return `${baseUrl}/api/v1/ai/chat`; + } + return `${baseUrl}/api/v1/ai/agents/${agentName}/chat`; +} + +/** + * Load persisted agent selection from localStorage. + * @internal — exported for testing + */ +export function loadSelectedAgent(): string { + try { + return localStorage.getItem(AGENT_STORAGE_KEY) ?? GENERAL_CHAT_VALUE; + } catch { + return GENERAL_CHAT_VALUE; + } +} + +/** + * Persist agent selection to localStorage. + * @internal — exported for testing + */ +export function saveSelectedAgent(agent: string): void { + try { + localStorage.setItem(AGENT_STORAGE_KEY, agent); + } catch { + // silently ignore + } +} + +/** + * Hook to fetch the list of available agents from the server. + */ +function useAgentList(baseUrl: string) { + const [agents, setAgents] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setLoading(true); + + fetch(`${baseUrl}/api/v1/ai/agents`, { credentials: 'include' }) + .then((res) => (res.ok ? res.json() : { agents: [] })) + .then((data: { agents?: AgentSummary[] }) => { + if (!cancelled) setAgents(data.agents ?? []); + }) + .catch(() => { + if (!cancelled) setAgents([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { cancelled = true; }; + }, [baseUrl]); + + return { agents, loading }; +} + // ── Tool Invocation State Labels ──────────────────────────────────── interface ToolInvocationDisplayProps { @@ -199,15 +278,17 @@ function ToolInvocationDisplay({ part, onApprove, onDeny }: ToolInvocationDispla export function AiChatPanel() { const { isOpen, setOpen, toggle } = useAiChatPanel(); const [input, setInput] = useState(''); + const [selectedAgent, setSelectedAgent] = useState(loadSelectedAgent); const scrollRef = useRef(null); const inputRef = useRef(null); const baseUrl = getApiBaseUrl(); + const { agents, loading: agentsLoading } = useAgentList(baseUrl); const initialMessages = useMemo(() => loadMessages() as UIMessage[], []); const transport = useMemo( - () => new DefaultChatTransport({ api: `${baseUrl}/api/v1/ai/chat` }), - [baseUrl], + () => new DefaultChatTransport({ api: chatApiUrl(baseUrl, selectedAgent) }), + [baseUrl, selectedAgent], ); const { messages, sendMessage, setMessages, status, error, addToolApprovalResponse } = useChat({ @@ -243,6 +324,14 @@ export function AiChatPanel() { saveMessages([]); }; + const handleAgentChange = useCallback((value: string) => { + setSelectedAgent(value); + saveSelectedAgent(value); + // Clear conversation when switching agents to avoid context confusion + setMessages([]); + saveMessages([]); + }, [setMessages]); + const handleSend = () => { const text = input.trim(); if (!text || isStreaming) return; @@ -300,27 +389,54 @@ export function AiChatPanel() { style={{ width: PANEL_WIDTH }} > {/* ── Header ── */} -
-
- - AI Chat +
+
+
+ + AI Chat +
+
+ + + + + +

Clear history

+
+
+ +
-
- - - - - -

Clear history

-
-
- + {/* ── Agent Selector ── */} +
+
diff --git a/apps/studio/test/ai-chat-panel.test.tsx b/apps/studio/test/ai-chat-panel.test.tsx index 8b7460b022..5c2ed094c0 100644 --- a/apps/studio/test/ai-chat-panel.test.tsx +++ b/apps/studio/test/ai-chat-panel.test.tsx @@ -199,3 +199,77 @@ describe('Messages with tool invocation parts', () => { expect(toolPart).toBeDefined(); }); }); + +// ═══════════════════════════════════════════════════════════════════ +// Agent Selector +// ═══════════════════════════════════════════════════════════════════ + +import { + AGENT_STORAGE_KEY, + GENERAL_CHAT_VALUE, + chatApiUrl, + loadSelectedAgent, + saveSelectedAgent, +} from '../src/components/AiChatPanel'; + +describe('Agent Selector', () => { + beforeEach(() => { + localStorage.clear(); + }); + + describe('chatApiUrl', () => { + it('should return general chat URL when no agent selected', () => { + expect(chatApiUrl('', null)).toBe('/api/v1/ai/chat'); + expect(chatApiUrl('', GENERAL_CHAT_VALUE)).toBe('/api/v1/ai/chat'); + }); + + it('should return agent-specific URL when agent selected', () => { + expect(chatApiUrl('', 'metadata_assistant')).toBe('/api/v1/ai/agents/metadata_assistant/chat'); + expect(chatApiUrl('', 'data_chat')).toBe('/api/v1/ai/agents/data_chat/chat'); + }); + + it('should include baseUrl prefix', () => { + expect(chatApiUrl('http://localhost:3000', 'metadata_assistant')) + .toBe('http://localhost:3000/api/v1/ai/agents/metadata_assistant/chat'); + expect(chatApiUrl('http://localhost:3000', GENERAL_CHAT_VALUE)) + .toBe('http://localhost:3000/api/v1/ai/chat'); + }); + }); + + describe('loadSelectedAgent', () => { + it('should return GENERAL_CHAT_VALUE when nothing stored', () => { + expect(loadSelectedAgent()).toBe(GENERAL_CHAT_VALUE); + }); + + it('should return stored agent name', () => { + localStorage.setItem(AGENT_STORAGE_KEY, 'metadata_assistant'); + expect(loadSelectedAgent()).toBe('metadata_assistant'); + }); + }); + + describe('saveSelectedAgent', () => { + it('should persist agent selection to localStorage', () => { + saveSelectedAgent('metadata_assistant'); + expect(localStorage.getItem(AGENT_STORAGE_KEY)).toBe('metadata_assistant'); + }); + + it('should overwrite previous selection', () => { + saveSelectedAgent('data_chat'); + saveSelectedAgent('metadata_assistant'); + expect(localStorage.getItem(AGENT_STORAGE_KEY)).toBe('metadata_assistant'); + }); + + it('should not throw when localStorage is unavailable', () => { + const originalSetItem = Storage.prototype.setItem; + Storage.prototype.setItem = () => { throw new Error('QuotaExceeded'); }; + expect(() => saveSelectedAgent('metadata_assistant')).not.toThrow(); + Storage.prototype.setItem = originalSetItem; + }); + }); + + describe('AGENT_STORAGE_KEY', () => { + it('should be a valid localStorage key', () => { + expect(AGENT_STORAGE_KEY).toBe('objectstack:ai-chat-agent'); + }); + }); +}); diff --git a/packages/services/service-ai/src/__tests__/chatbot-features.test.ts b/packages/services/service-ai/src/__tests__/chatbot-features.test.ts index 6583c4da6d..defdd89481 100644 --- a/packages/services/service-ai/src/__tests__/chatbot-features.test.ts +++ b/packages/services/service-ai/src/__tests__/chatbot-features.test.ts @@ -680,6 +680,45 @@ describe('AgentRuntime', () => { expect(options.model).toBeUndefined(); }); }); + + describe('listAgents', () => { + it('should return summaries of all active agents', async () => { + (metadataService.list as any).mockResolvedValue([ + DATA_CHAT_AGENT, + METADATA_ASSISTANT_AGENT, + ]); + const agents = await runtime.listAgents(); + expect(agents).toHaveLength(2); + expect(agents[0]).toEqual({ name: 'data_chat', label: 'Data Assistant', role: 'Business Data Analyst' }); + expect(agents[1]).toEqual({ name: 'metadata_assistant', label: 'Metadata Assistant', role: 'Schema Architect' }); + }); + + it('should filter out inactive agents', async () => { + (metadataService.list as any).mockResolvedValue([ + DATA_CHAT_AGENT, + { ...METADATA_ASSISTANT_AGENT, active: false }, + ]); + const agents = await runtime.listAgents(); + expect(agents).toHaveLength(1); + expect(agents[0].name).toBe('data_chat'); + }); + + it('should return empty array when no agents registered', async () => { + (metadataService.list as any).mockResolvedValue([]); + const agents = await runtime.listAgents(); + expect(agents).toEqual([]); + }); + + it('should skip malformed agent metadata', async () => { + (metadataService.list as any).mockResolvedValue([ + DATA_CHAT_AGENT, + { name: 'bad', label: 'Bad' }, // missing required fields + ]); + const agents = await runtime.listAgents(); + expect(agents).toHaveLength(1); + expect(agents[0].name).toBe('data_chat'); + }); + }); }); // ═══════════════════════════════════════════════════════════════════ @@ -702,19 +741,37 @@ describe('Agent Routes', () => { if (name === 'inactive_agent') return { ...DATA_CHAT_AGENT, name: 'inactive_agent', active: false }; return undefined; }), + list: vi.fn(async () => [DATA_CHAT_AGENT, METADATA_ASSISTANT_AGENT]), }); runtime = new AgentRuntime(metadataService); routes = buildAgentRoutes(aiService, runtime, silentLogger); }); - it('should define one agent chat route', () => { - expect(routes).toHaveLength(1); - expect(routes[0].method).toBe('POST'); - expect(routes[0].path).toBe('/api/v1/ai/agents/:agentName/chat'); + it('should define a GET list route and a POST chat route', () => { + expect(routes).toHaveLength(2); + expect(routes[0].method).toBe('GET'); + expect(routes[0].path).toBe('/api/v1/ai/agents'); + expect(routes[1].method).toBe('POST'); + expect(routes[1].path).toBe('/api/v1/ai/agents/:agentName/chat'); + }); + + // ── GET /api/v1/ai/agents ── + + it('should return list of active agents', async () => { + const listRoute = routes.find(r => r.method === 'GET')!; + const resp = await listRoute.handler({}); + expect(resp.status).toBe(200); + const body = resp.body as { agents: Array<{ name: string; label: string; role: string }> }; + expect(body.agents).toHaveLength(2); + expect(body.agents[0].name).toBe('data_chat'); + expect(body.agents[1].name).toBe('metadata_assistant'); }); + // ── POST /api/v1/ai/agents/:agentName/chat ── + it('should return 400 if agentName is missing', async () => { - const resp = await routes[0].handler({ + const chatRoute = routes.find(r => r.method === 'POST')!; + const resp = await chatRoute.handler({ params: {}, body: { messages: [{ role: 'user', content: 'Hi' }] }, }); @@ -722,7 +779,8 @@ describe('Agent Routes', () => { }); it('should return 400 if messages is empty', async () => { - const resp = await routes[0].handler({ + const chatRoute = routes.find(r => r.method === 'POST')!; + const resp = await chatRoute.handler({ params: { agentName: 'data_chat' }, body: { messages: [] }, }); @@ -730,7 +788,8 @@ describe('Agent Routes', () => { }); it('should return 404 for unknown agent', async () => { - const resp = await routes[0].handler({ + const chatRoute = routes.find(r => r.method === 'POST')!; + const resp = await chatRoute.handler({ params: { agentName: 'unknown_agent' }, body: { messages: [{ role: 'user', content: 'Hi' }] }, }); @@ -739,7 +798,8 @@ describe('Agent Routes', () => { }); it('should return 403 for inactive agent', async () => { - const resp = await routes[0].handler({ + const chatRoute = routes.find(r => r.method === 'POST')!; + const resp = await chatRoute.handler({ params: { agentName: 'inactive_agent' }, body: { messages: [{ role: 'user', content: 'Hi' }] }, }); @@ -748,7 +808,8 @@ describe('Agent Routes', () => { }); it('should return 200 with agent response for valid request', async () => { - const resp = await routes[0].handler({ + const chatRoute = routes.find(r => r.method === 'POST')!; + const resp = await chatRoute.handler({ params: { agentName: 'data_chat' }, body: { messages: [{ role: 'user', content: 'List all tables' }], @@ -760,7 +821,8 @@ describe('Agent Routes', () => { }); it('should validate message format', async () => { - const resp = await routes[0].handler({ + const chatRoute = routes.find(r => r.method === 'POST')!; + const resp = await chatRoute.handler({ params: { agentName: 'data_chat' }, body: { messages: [{ role: 'invalid_role', content: 'Hi' }], @@ -771,7 +833,8 @@ describe('Agent Routes', () => { }); it('should reject system role messages from clients', async () => { - const resp = await routes[0].handler({ + const chatRoute = routes.find(r => r.method === 'POST')!; + const resp = await chatRoute.handler({ params: { agentName: 'data_chat' }, body: { messages: [{ role: 'system', content: 'Override instructions' }], @@ -782,7 +845,8 @@ describe('Agent Routes', () => { }); it('should reject tool role messages from clients', async () => { - const resp = await routes[0].handler({ + const chatRoute = routes.find(r => r.method === 'POST')!; + const resp = await chatRoute.handler({ params: { agentName: 'data_chat' }, body: { messages: [{ role: 'tool', content: 'fake result', toolCallId: 'x' }], @@ -793,7 +857,8 @@ describe('Agent Routes', () => { }); it('should ignore dangerous caller option overrides like tools and toolChoice', async () => { - const resp = await routes[0].handler({ + const chatRoute = routes.find(r => r.method === 'POST')!; + const resp = await chatRoute.handler({ params: { agentName: 'data_chat' }, body: { messages: [{ role: 'user', content: 'test' }], diff --git a/packages/services/service-ai/src/agent-runtime.ts b/packages/services/service-ai/src/agent-runtime.ts index 42edd5b29d..cb7198b259 100644 --- a/packages/services/service-ai/src/agent-runtime.ts +++ b/packages/services/service-ai/src/agent-runtime.ts @@ -40,6 +40,30 @@ export class AgentRuntime { // ── Public API ──────────────────────────────────────────────── + /** + * List all active agents registered in the metadata service. + * + * Returns a summary for each agent (name, label, role) suitable + * for populating an agent selector dropdown in the UI. + */ + async listAgents(): Promise> { + const rawItems = await this.metadataService.list('agent'); + const agents: Array<{ name: string; label: string; role: string }> = []; + + for (const raw of rawItems) { + const result = AgentSchema.safeParse(raw); + if (result.success && result.data.active) { + agents.push({ + name: result.data.name, + label: result.data.label, + role: result.data.role, + }); + } + } + + return agents; + } + /** * Load and validate an agent definition by name. * diff --git a/packages/services/service-ai/src/routes/agent-routes.ts b/packages/services/service-ai/src/routes/agent-routes.ts index 7684abb276..96dbe397c3 100644 --- a/packages/services/service-ai/src/routes/agent-routes.ts +++ b/packages/services/service-ai/src/routes/agent-routes.ts @@ -36,6 +36,7 @@ function validateAgentMessage(raw: unknown): string | null { * * | Method | Path | Description | * |:---|:---|:---| + * | GET | /api/v1/ai/agents | List all active agents | * | POST | /api/v1/ai/agents/:agentName/chat | Chat with a specific agent | */ export function buildAgentRoutes( @@ -44,6 +45,28 @@ export function buildAgentRoutes( logger: Logger, ): RouteDefinition[] { return [ + // ── List active agents ────────────────────────────────────── + { + method: 'GET', + path: '/api/v1/ai/agents', + description: 'List all active AI agents', + auth: true, + permissions: ['ai:chat'], + handler: async () => { + try { + const agents = await agentRuntime.listAgents(); + return { status: 200, body: { agents } }; + } catch (err) { + logger.error( + '[AI Route] /agents list error', + err instanceof Error ? err : undefined, + ); + return { status: 500, body: { error: 'Internal AI service error' } }; + } + }, + }, + + // ── Chat with a specific agent ────────────────────────────── { method: 'POST', path: '/api/v1/ai/agents/:agentName/chat', From bbdcad9f3ae5f07a9ce7575e08d527b2c3049a11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:35:26 +0000 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20address=20all=20code=20review=20comm?= =?UTF-8?q?ents=20=E2=80=94=20guard=20formatToolArgs/formatToolOutput,=20v?= =?UTF-8?q?alidate=20agent=20selection,=20remove=20unenforced=20requiresCo?= =?UTF-8?q?nfirmation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/f75e2ec2-9b8a-4e71-916e-8193b834ad01 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- apps/studio/src/components/AiChatPanel.tsx | 26 +++++++++++++++++-- .../src/__tests__/metadata-tools.test.ts | 8 +++--- .../src/tools/create-object.tool.ts | 6 ++++- .../service-ai/src/tools/delete-field.tool.ts | 5 +++- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/apps/studio/src/components/AiChatPanel.tsx b/apps/studio/src/components/AiChatPanel.tsx index 2ba557f0f0..ced84f16d3 100644 --- a/apps/studio/src/components/AiChatPanel.tsx +++ b/apps/studio/src/components/AiChatPanel.tsx @@ -62,7 +62,12 @@ function formatToolArgs(input: unknown): string { return entries .slice(0, 4) .map(([k, v]) => { - const val = typeof v === 'string' ? v : JSON.stringify(v); + let val: string; + try { + val = typeof v === 'string' ? v : (JSON.stringify(v) ?? String(v)); + } catch { + val = String(v); + } const display = val.length > 30 ? val.slice(0, 30) + '…' : val; return `${k}: ${display}`; }) @@ -80,7 +85,12 @@ function isToolPart(part: UIMessage['parts'][number]): part is Extract maxLen ? raw.slice(0, maxLen) + '…' : raw; } @@ -284,6 +294,18 @@ export function AiChatPanel() { const baseUrl = getApiBaseUrl(); const { agents, loading: agentsLoading } = useAgentList(baseUrl); + // Validate persisted agent against fetched list — fall back to general + // chat if the previously selected agent is no longer available. + useEffect(() => { + if (agentsLoading) return; + if (selectedAgent === GENERAL_CHAT_VALUE) return; + const isValid = agents.some((a) => a.name === selectedAgent); + if (!isValid) { + setSelectedAgent(GENERAL_CHAT_VALUE); + saveSelectedAgent(GENERAL_CHAT_VALUE); + } + }, [agents, agentsLoading, selectedAgent]); + const initialMessages = useMemo(() => loadMessages() as UIMessage[], []); const transport = useMemo( diff --git a/packages/services/service-ai/src/__tests__/metadata-tools.test.ts b/packages/services/service-ai/src/__tests__/metadata-tools.test.ts index 7c007f4b4e..3357191a64 100644 --- a/packages/services/service-ai/src/__tests__/metadata-tools.test.ts +++ b/packages/services/service-ai/src/__tests__/metadata-tools.test.ts @@ -123,12 +123,12 @@ describe('Individual Tool Metadata (.tool.ts)', () => { }); } - it('should mark create_object as requiresConfirmation', () => { - expect(createObjectTool.requiresConfirmation).toBe(true); + it('should not set requiresConfirmation on create_object (server-side enforcement not yet implemented)', () => { + expect(createObjectTool.requiresConfirmation).toBe(false); }); - it('should mark delete_field as requiresConfirmation', () => { - expect(deleteFieldTool.requiresConfirmation).toBe(true); + it('should not set requiresConfirmation on delete_field (server-side enforcement not yet implemented)', () => { + expect(deleteFieldTool.requiresConfirmation).toBe(false); }); it('should not mark read-only tools as requiresConfirmation', () => { diff --git a/packages/services/service-ai/src/tools/create-object.tool.ts b/packages/services/service-ai/src/tools/create-object.tool.ts index fa8ad9789a..46b4d28b6d 100644 --- a/packages/services/service-ai/src/tools/create-object.tool.ts +++ b/packages/services/service-ai/src/tools/create-object.tool.ts @@ -17,7 +17,11 @@ export const createObjectTool = defineTool({ 'Use this when the user wants to create a new entity, table, or data model.', category: 'data', builtIn: true, - requiresConfirmation: true, + // NOTE: requiresConfirmation is intentionally false (default) because the + // server-side tool-call loop in AIService.chatWithTools/streamChatWithTools + // executes tool calls immediately without checking this flag. The flag + // should only be set once server-side approval gating is implemented to + // avoid giving users a false sense of safety. parameters: { type: 'object', properties: { diff --git a/packages/services/service-ai/src/tools/delete-field.tool.ts b/packages/services/service-ai/src/tools/delete-field.tool.ts index 04d3fe598e..405f1bd212 100644 --- a/packages/services/service-ai/src/tools/delete-field.tool.ts +++ b/packages/services/service-ai/src/tools/delete-field.tool.ts @@ -16,7 +16,10 @@ export const deleteFieldTool = defineTool({ 'Use this when the user explicitly wants to remove an attribute or column from a table.', category: 'data', builtIn: true, - requiresConfirmation: true, + // NOTE: requiresConfirmation is intentionally false (default) because the + // server-side tool-call loop in AIService.chatWithTools/streamChatWithTools + // executes tool calls immediately without checking this flag. The flag + // should only be set once server-side approval gating is implemented. parameters: { type: 'object', properties: {