Skip to content

Commit 61cdd27

Browse files
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>
1 parent d3a69ed commit 61cdd27

10 files changed

Lines changed: 481 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2525
absent, fixing the CI `@objectstack/client#test` failure.
2626

2727
### Added
28+
- **Metadata Assistant Agent (`service-ai`)** — New `metadata_assistant` agent definition that
29+
binds all 6 metadata management tools (`create_object`, `add_field`, `modify_field`,
30+
`delete_field`, `list_metadata_objects`, `describe_metadata_object`). Includes a tailored
31+
system prompt that guides the AI to use snake_case naming, verify existing schemas before
32+
modifications, and warn about destructive operations. Configured with `react` planning
33+
strategy (10 iterations, replan enabled) for multi-step schema design conversations.
34+
- **Tool Confirmation Flags** — Added `requiresConfirmation: true` to `create_object` and
35+
`delete_field` tool definitions. These destructive/creation operations now signal to the
36+
frontend that user approval is needed before execution.
37+
- **Frontend Tool Call Display (`AiChatPanel`)** — Enhanced the AI Chat Panel to render tool
38+
invocation parts from the Vercel AI SDK v6 stream protocol. Displays tool call status with
39+
visual indicators:
40+
- **Calling**: Spinner animation with tool name and argument summary
41+
- **Confirmation**: Yellow-bordered card with Approve/Deny buttons for `requiresConfirmation` tools
42+
- **Success**: Green success indicator with result preview
43+
- **Error**: Red error indicator with error message
44+
- **Denied**: Muted indicator for user-denied operations
45+
- **Operation Confirmation Mechanism** — Integrated the Vercel AI SDK `addToolApprovalResponse`
46+
hook to support approval/denial workflows for tools marked with `requiresConfirmation`.
47+
When the server sends an `approval-requested` state, the chat panel shows Approve and Deny
48+
buttons. User decisions are sent back to the server to continue or abort the tool execution.
2849
- **Metadata Management Tools (`service-ai`)** — Added 6 built-in AI tools for metadata
2950
CRUD operations, each defined as a first-class `Tool` metadata file using `defineTool()`
3051
from `@objectstack/spec/ai`:

apps/studio/src/components/AiChatPanel.tsx

Lines changed: 188 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { useState, useRef, useEffect, useMemo } from 'react';
44
import { useChat } from '@ai-sdk/react';
55
import { DefaultChatTransport } from 'ai';
66
import type { UIMessage } from 'ai';
7-
import { Bot, X, Send, Trash2, Sparkles } from 'lucide-react';
7+
import {
8+
Bot, X, Send, Trash2, Sparkles,
9+
Wrench, CheckCircle2, XCircle, Loader2, ShieldAlert,
10+
} from 'lucide-react';
811
import { Button } from '@/components/ui/button';
912
import { ScrollArea } from '@/components/ui/scroll-area';
1013
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@@ -25,6 +28,168 @@ function getMessageText(msg: UIMessage): string {
2528
.join('');
2629
}
2730

31+
/**
32+
* Convert a snake_case tool name to a human-readable label.
33+
*/
34+
function formatToolName(name: string): string {
35+
return name
36+
.replace(/_/g, ' ')
37+
.replace(/\b\w/g, (c) => c.toUpperCase());
38+
}
39+
40+
/**
41+
* Render a concise summary of tool input arguments.
42+
*/
43+
function formatToolArgs(input: unknown): string {
44+
if (!input || typeof input !== 'object') return '';
45+
const entries = Object.entries(input as Record<string, unknown>);
46+
if (entries.length === 0) return '';
47+
return entries
48+
.slice(0, 4)
49+
.map(([k, v]) => {
50+
const val = typeof v === 'string' ? v : JSON.stringify(v);
51+
const display = typeof val === 'string' && val.length > 30 ? val.slice(0, 30) + '…' : val;
52+
return `${k}: ${display}`;
53+
})
54+
.join(', ');
55+
}
56+
57+
/**
58+
* Type guard to check if a message part is a tool invocation (dynamic-tool).
59+
*/
60+
function isToolPart(part: UIMessage['parts'][number]): part is Extract<UIMessage['parts'][number], { type: 'dynamic-tool' }> {
61+
return part.type === 'dynamic-tool';
62+
}
63+
64+
// ── Tool Invocation State Labels ────────────────────────────────────
65+
66+
interface ToolInvocationDisplayProps {
67+
part: Extract<UIMessage['parts'][number], { type: 'dynamic-tool' }>;
68+
onApprove?: (approvalId: string) => void;
69+
onDeny?: (approvalId: string) => void;
70+
}
71+
72+
/**
73+
* Renders a single tool invocation part with appropriate status indicator.
74+
*/
75+
function ToolInvocationDisplay({ part, onApprove, onDeny }: ToolInvocationDisplayProps) {
76+
const toolLabel = formatToolName(part.toolName);
77+
const argsText = formatToolArgs(part.input);
78+
79+
switch (part.state) {
80+
case 'input-streaming':
81+
case 'input-available':
82+
return (
83+
<div
84+
data-testid="tool-invocation-calling"
85+
className="flex items-start gap-2 rounded-md border border-border/50 bg-muted/50 px-2.5 py-2 text-xs"
86+
>
87+
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-primary" />
88+
<div className="min-w-0">
89+
<span className="font-medium">Calling {toolLabel}</span>
90+
{argsText && (
91+
<p className="mt-0.5 truncate text-muted-foreground">{argsText}</p>
92+
)}
93+
</div>
94+
</div>
95+
);
96+
97+
case 'approval-requested':
98+
return (
99+
<div
100+
data-testid="tool-invocation-confirm"
101+
className="flex flex-col gap-2 rounded-md border border-yellow-500/40 bg-yellow-500/10 px-2.5 py-2 text-xs"
102+
>
103+
<div className="flex items-start gap-2">
104+
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0 text-yellow-600 dark:text-yellow-400" />
105+
<div className="min-w-0">
106+
<span className="font-medium">Confirm: {toolLabel}</span>
107+
{argsText && (
108+
<p className="mt-0.5 text-muted-foreground">{argsText}</p>
109+
)}
110+
</div>
111+
</div>
112+
{part.approval && onApprove && onDeny && (
113+
<div className="flex gap-2 pl-5">
114+
<Button
115+
size="sm"
116+
variant="outline"
117+
className="h-6 px-2 text-xs"
118+
onClick={() => onApprove(part.approval!.id)}
119+
>
120+
<CheckCircle2 className="mr-1 h-3 w-3" />
121+
Approve
122+
</Button>
123+
<Button
124+
size="sm"
125+
variant="ghost"
126+
className="h-6 px-2 text-xs text-destructive hover:text-destructive"
127+
onClick={() => onDeny(part.approval!.id)}
128+
>
129+
<XCircle className="mr-1 h-3 w-3" />
130+
Deny
131+
</Button>
132+
</div>
133+
)}
134+
</div>
135+
);
136+
137+
case 'output-available':
138+
return (
139+
<div
140+
data-testid="tool-invocation-result"
141+
className="flex items-start gap-2 rounded-md border border-green-500/30 bg-green-500/10 px-2.5 py-2 text-xs"
142+
>
143+
<CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-green-600 dark:text-green-400" />
144+
<div className="min-w-0">
145+
<span className="font-medium">{toolLabel}</span>
146+
<p className="mt-0.5 text-muted-foreground truncate">
147+
{typeof part.output === 'string'
148+
? part.output.length > 80 ? part.output.slice(0, 80) + '…' : part.output
149+
: JSON.stringify(part.output).slice(0, 80)}
150+
</p>
151+
</div>
152+
</div>
153+
);
154+
155+
case 'output-error':
156+
return (
157+
<div
158+
data-testid="tool-invocation-error"
159+
className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-xs"
160+
>
161+
<XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
162+
<div className="min-w-0">
163+
<span className="font-medium">{toolLabel} failed</span>
164+
<p className="mt-0.5 text-destructive/80">{part.errorText}</p>
165+
</div>
166+
</div>
167+
);
168+
169+
case 'output-denied':
170+
return (
171+
<div
172+
data-testid="tool-invocation-denied"
173+
className="flex items-start gap-2 rounded-md border border-border/50 bg-muted/50 px-2.5 py-2 text-xs"
174+
>
175+
<XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
176+
<span className="font-medium text-muted-foreground">{toolLabel} — denied</span>
177+
</div>
178+
);
179+
180+
default:
181+
return (
182+
<div
183+
data-testid="tool-invocation-unknown"
184+
className="flex items-start gap-2 rounded-md border border-border/50 bg-muted/50 px-2.5 py-2 text-xs"
185+
>
186+
<Wrench className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
187+
<span className="font-medium">{toolLabel}</span>
188+
</div>
189+
);
190+
}
191+
}
192+
28193
export function AiChatPanel() {
29194
const { isOpen, setOpen, toggle } = useAiChatPanel();
30195
const [input, setInput] = useState('');
@@ -39,7 +204,7 @@ export function AiChatPanel() {
39204
[baseUrl],
40205
);
41206

42-
const { messages, sendMessage, setMessages, status, error } = useChat({
207+
const { messages, sendMessage, setMessages, status, error, addToolApprovalResponse } = useChat({
43208
transport,
44209
messages: initialMessages,
45210
});
@@ -167,12 +332,14 @@ export function AiChatPanel() {
167332
)}
168333
{messages.map((msg) => {
169334
const text = getMessageText(msg);
170-
if (!text && msg.role !== 'user') return null;
335+
const toolParts = (msg.parts ?? []).filter(isToolPart);
336+
const hasContent = !!text || toolParts.length > 0;
337+
if (!hasContent && msg.role !== 'user') return null;
171338
return (
172339
<div
173340
key={msg.id}
174341
className={cn(
175-
'flex flex-col gap-1 rounded-lg px-3 py-2 text-sm',
342+
'flex flex-col gap-1.5 rounded-lg px-3 py-2 text-sm',
176343
msg.role === 'user'
177344
? 'ml-8 bg-primary text-primary-foreground'
178345
: 'mr-8 bg-muted text-foreground',
@@ -181,7 +348,23 @@ export function AiChatPanel() {
181348
<span className="text-[10px] font-medium opacity-60 uppercase">
182349
{msg.role === 'user' ? 'You' : 'Assistant'}
183350
</span>
184-
<div className="whitespace-pre-wrap break-words">{text}</div>
351+
{text && <div className="whitespace-pre-wrap break-words">{text}</div>}
352+
{toolParts.map((toolPart) => (
353+
<ToolInvocationDisplay
354+
key={toolPart.toolCallId}
355+
part={toolPart}
356+
onApprove={(approvalId) =>
357+
addToolApprovalResponse({ id: approvalId, approved: true })
358+
}
359+
onDeny={(approvalId) =>
360+
addToolApprovalResponse({
361+
id: approvalId,
362+
approved: false,
363+
reason: 'User denied the operation',
364+
})
365+
}
366+
/>
367+
))}
185368
</div>
186369
);
187370
})}

apps/studio/test/ai-chat-panel.test.tsx

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,39 @@ function makeMsg(overrides: { id: string; role: 'user' | 'assistant'; content: s
1616
};
1717
}
1818

19+
/**
20+
* Create a UIMessage that includes tool invocation parts for testing.
21+
*/
22+
function makeMsgWithToolParts(overrides: {
23+
id: string;
24+
role: 'user' | 'assistant';
25+
text?: string;
26+
toolParts?: Array<{ toolName: string; toolCallId: string; state: string; input?: unknown; output?: unknown; errorText?: string }>;
27+
}): UIMessage {
28+
const parts: UIMessage['parts'] = [];
29+
if (overrides.text) {
30+
parts.push({ type: 'text' as const, text: overrides.text });
31+
}
32+
if (overrides.toolParts) {
33+
for (const tp of overrides.toolParts) {
34+
parts.push({
35+
type: 'dynamic-tool',
36+
toolName: tp.toolName,
37+
toolCallId: tp.toolCallId,
38+
state: tp.state,
39+
input: tp.input,
40+
output: tp.output,
41+
errorText: tp.errorText,
42+
} as unknown as UIMessage['parts'][number]);
43+
}
44+
}
45+
return {
46+
id: overrides.id,
47+
role: overrides.role,
48+
parts,
49+
};
50+
}
51+
1952
describe('use-ai-chat-panel', () => {
2053
beforeEach(() => {
2154
localStorage.clear();
@@ -94,3 +127,75 @@ describe('AiChatPanel constants', () => {
94127
expect(localStorage.getItem('objectstack:ai-chat-messages')).toBeTruthy();
95128
});
96129
});
130+
131+
// ═══════════════════════════════════════════════════════════════════
132+
// Tool Invocation Message Parts
133+
// ═══════════════════════════════════════════════════════════════════
134+
135+
describe('Messages with tool invocation parts', () => {
136+
beforeEach(() => {
137+
localStorage.clear();
138+
});
139+
140+
it('should persist and restore messages containing tool invocation parts', () => {
141+
const msg = makeMsgWithToolParts({
142+
id: 'a1',
143+
role: 'assistant',
144+
text: 'Creating object...',
145+
toolParts: [
146+
{
147+
toolName: 'create_object',
148+
toolCallId: 'tc_1',
149+
state: 'output-available',
150+
input: { name: 'project', label: 'Project' },
151+
output: { name: 'project', label: 'Project', fieldCount: 0 },
152+
},
153+
],
154+
});
155+
saveMessages([msg]);
156+
const restored = loadMessages();
157+
expect(restored).toHaveLength(1);
158+
expect(restored[0].parts).toHaveLength(2); // text + tool part
159+
});
160+
161+
it('should handle messages with only tool parts (no text)', () => {
162+
const msg = makeMsgWithToolParts({
163+
id: 'a2',
164+
role: 'assistant',
165+
toolParts: [
166+
{
167+
toolName: 'list_metadata_objects',
168+
toolCallId: 'tc_2',
169+
state: 'output-available',
170+
input: {},
171+
output: { objects: [], totalCount: 0 },
172+
},
173+
],
174+
});
175+
saveMessages([msg]);
176+
const restored = loadMessages();
177+
expect(restored).toHaveLength(1);
178+
expect(restored[0].parts).toHaveLength(1); // only tool part
179+
});
180+
181+
it('should persist tool error parts', () => {
182+
const msg = makeMsgWithToolParts({
183+
id: 'a3',
184+
role: 'assistant',
185+
toolParts: [
186+
{
187+
toolName: 'create_object',
188+
toolCallId: 'tc_3',
189+
state: 'output-error',
190+
input: { name: 'Bad Name' },
191+
errorText: 'Invalid object name "Bad Name". Must be snake_case.',
192+
},
193+
],
194+
});
195+
saveMessages([msg]);
196+
const restored = loadMessages();
197+
expect(restored).toHaveLength(1);
198+
const toolPart = restored[0].parts.find((p: { type: string }) => p.type === 'dynamic-tool');
199+
expect(toolPart).toBeDefined();
200+
});
201+
});

0 commit comments

Comments
 (0)