diff --git a/src/langbot/pkg/api/http/controller/groups/assistant.py b/src/langbot/pkg/api/http/controller/groups/assistant.py index a6db7d8c9..f902c6f44 100644 --- a/src/langbot/pkg/api/http/controller/groups/assistant.py +++ b/src/langbot/pkg/api/http/controller/groups/assistant.py @@ -1,6 +1,7 @@ """Web-session-only assistant endpoints; resource tools use the existing service layer.""" import quart +from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, ValidationError from .. import group @@ -14,6 +15,7 @@ class TurnInput(BaseModel): revision: int = Field(ge=0, strict=True) text: str | None = Field(default=None, min_length=1, max_length=8000) approved: bool | None = Field(default=None, strict=True) + model_uuid: UUID | None = None @group.group_class('assistant', '/api/v1/assistant') @@ -47,6 +49,7 @@ class AssistantRouterGroup(group.RouterGroup): body.revision, body.text, body.approved, + str(body.model_uuid) if body.model_uuid else None, ) return self.success(data=service.public_view(conversation)) except ValidationError: diff --git a/src/langbot/pkg/api/http/service/assistant.py b/src/langbot/pkg/api/http/service/assistant.py index 80d171ed6..19701b9c8 100644 --- a/src/langbot/pkg/api/http/service/assistant.py +++ b/src/langbot/pkg/api/http/service/assistant.py @@ -86,12 +86,26 @@ class AssistantService: @staticmethod def public_view(conversation): messages = [] + calls = {} for message in conversation['messages']: + calls.update({call['id']: call['function'] for call in message.get('tool_calls') or []}) content = message.get('content') or '' if isinstance(content, list): content = '\n'.join(item.get('text') or '' for item in content if item.get('type') == 'text') if content: - messages.append({'role': message['role'], 'content': content}) + visible = {'role': message['role'], 'content': content} + if message['role'] == 'tool': + function = calls.get(message.get('tool_call_id'), {}) + try: + arguments = json.loads(function.get('arguments') or '{}') + except json.JSONDecodeError: + arguments = {'unparsed': function['arguments']} + visible['tool'] = { + 'name': function.get('name', ''), + 'arguments': arguments, + 'result': json.loads(content), + } + messages.append(visible) pending = [] if conversation['status'] == 'approval': for call in conversation['messages'][-1].get('tool_calls') or []: @@ -109,6 +123,7 @@ class AssistantService: 'pending': pending, 'error': conversation['error'], 'model_name': conversation['model_name'], + 'model_uuid': conversation['model_uuid'], } async def _save(self, context, conversation, status, error=None): @@ -131,8 +146,10 @@ class AssistantService: raise AssistantError('stale_turn') conversation.update(status=status, error=error) - async def turn(self, context, conversation_id, revision, text=None, approved=None): + async def turn(self, context, conversation_id, revision, text=None, approved=None, model_uuid=None): require_permission(context, Permission.RUNTIME_OPERATE) + if model_uuid is not None and text is None: + raise AssistantError('invalid_input', 400) if self._slots.locked(): raise AssistantError('busy', 429) async with self._slots: @@ -142,6 +159,16 @@ class AssistantService: raise AssistantError('stale_turn') if text is not None and len(conversation['messages']) >= 100: raise AssistantError('conversation_full') + selected_model = None + if model_uuid is not None: + try: + selected_model = await self.ap.model_mgr.get_model_by_uuid( + ExecutionContext.from_request(context), model_uuid + ) + if 'func_call' not in (selected_model.model_entity.abilities or []): + raise ValueError('Model does not support tool calls') + except Exception as exc: + raise AssistantError('model_unavailable', 400) from exc result = await self.ap.persistence_mgr.execute_async( sa.update(Conversation) .where( @@ -156,6 +183,15 @@ class AssistantService: conversation['revision'] += 1 try: async with asyncio.timeout(120): + if model_uuid is not None and model_uuid != conversation['model_uuid']: + # Provider signatures and response IDs belong to the previous model. + for message in conversation['messages']: + message['provider_specific_fields'] = None + message['resp_message_id'] = None + for call in message.get('tool_calls') or []: + call['provider_specific_fields'] = None + conversation['model_uuid'] = model_uuid + conversation['model_name'] = selected_model.model_entity.name if text is not None: conversation['messages'].append(Message(role='user', content=text).model_dump(mode='json')) await self._save(context, conversation, 'running') @@ -164,7 +200,9 @@ class AssistantService: recommended = await self.ap.space_service.get_recommended_chat_model(context) conversation['model_uuid'] = recommended['uuid'] execution = ExecutionContext.from_request(context) - model = await self.ap.model_mgr.get_model_by_uuid(execution, conversation['model_uuid']) + model = selected_model or await self.ap.model_mgr.get_model_by_uuid( + execution, conversation['model_uuid'] + ) if 'func_call' not in (model.model_entity.abilities or []): raise ValueError('Recommended model does not support tool calls') conversation['model_name'] = model.model_entity.name diff --git a/tests/unit_tests/api/test_management_assistant.py b/tests/unit_tests/api/test_management_assistant.py index 58669a042..a3f993e6b 100644 --- a/tests/unit_tests/api/test_management_assistant.py +++ b/tests/unit_tests/api/test_management_assistant.py @@ -107,6 +107,12 @@ async def test_confirmation_is_exact_once_and_private(assistant): assert saved['messages'][1]['provider_specific_fields']['thought_signature'] == 'preserved' assert provider.invoke_llm.call_args.kwargs['query'] is None assert provider.invoke_llm.call_args.kwargs['execution_context'].workspace_uuid == ctx.workspace_uuid + tool_message = next(message for message in service.public_view(saved)['messages'] if message['role'] == 'tool') + assert tool_message['tool'] == { + 'name': 'create_pipeline', + 'arguments': {'name': 'Demo', 'description': 'Test draft'}, + 'result': {'uuid': 'created-pipeline', 'url': '/home/pipelines?id=created-pipeline', 'configured': False}, + } @pytest.mark.asyncio @@ -147,6 +153,23 @@ def test_tool_arguments_cannot_select_identity_or_shell(): validate_call(context(), 'exec', {'command': 'echo unsafe'}) +def test_rejected_malformed_tool_call_remains_readable(): + message = proposal().model_dump(mode='json') + message['tool_calls'][0]['function']['arguments'] = '{invalid' + conversation = dict( + uuid='chat', + revision=1, + status='ready', + error=None, + model_name=None, + model_uuid=None, + messages=[message, {'role': 'tool', 'tool_call_id': 'call-1', 'content': '{"error":"Invalid arguments"}'}], + ) + visible = AssistantService.public_view(conversation)['messages'][-1]['tool'] + assert visible['result']['error'] == 'Invalid arguments' + assert visible['arguments'] == {'unparsed': '{invalid'} + + @pytest.mark.asyncio async def test_resource_readers_match_application_services(): from langbot.pkg.core.app import Application @@ -175,3 +198,39 @@ async def test_resource_readers_match_application_services(): 'items': [{'name': kind}], } reader.assert_awaited_once_with(ctx) + + +@pytest.mark.asyncio +async def test_model_switch_preserves_history_and_rejects_invalid_selection(assistant): + service, ap, provider = assistant + ctx = context() + conversation = await service.create(ctx) + cid = conversation['uuid'] + await service.turn(ctx, cid, 0, text='Create') + with pytest.raises(AssistantError, match='invalid_input'): + await service.turn(ctx, cid, 1, approved=True, model_uuid='other') + ap.pipeline_service.create_pipeline.assert_not_awaited() + await service.turn(ctx, cid, 1, approved=True) + for invalid in (ValueError('not in workspace'), SimpleNamespace(model_entity=SimpleNamespace(abilities=[]))): + ap.model_mgr.get_model_by_uuid.side_effect = [invalid] + with pytest.raises(AssistantError, match='model_unavailable'): + await service.turn(ctx, cid, 2, text='Continue', model_uuid='invalid') + saved = await service.get(ctx, cid) + assert saved['revision'] == 2 and saved['status'] == 'ready' + next_provider = SimpleNamespace(invoke_llm=AsyncMock(return_value=Message(role='assistant', content='Switched'))) + ap.model_mgr.get_model_by_uuid.side_effect = None + ap.model_mgr.get_model_by_uuid.return_value = SimpleNamespace( + provider=next_provider, + model_entity=SimpleNamespace(name='second-model', abilities=['func_call'], extra_args={}), + ) + switched = await service.turn(ctx, cid, 2, text='Continue', model_uuid='second') + assert switched['status'] == 'ready' + assert service.public_view(switched)['model_uuid'] == 'second' + assert switched['model_name'] == 'second-model' + history = next_provider.invoke_llm.call_args.kwargs['messages'] + assert [m.content for m in history if m.role == 'user'] == ['Create', 'Continue'] + assert any(m.role == 'tool' for m in history) + assert all(m.provider_specific_fields is None for m in history) + ap.model_mgr.get_model_by_uuid.assert_awaited_with( + next_provider.invoke_llm.call_args.kwargs['execution_context'], 'second' + ) diff --git a/web/src/app/home/components/AssistantToolResult.tsx b/web/src/app/home/components/AssistantToolResult.tsx new file mode 100644 index 000000000..94895a627 --- /dev/null +++ b/web/src/app/home/components/AssistantToolResult.tsx @@ -0,0 +1,120 @@ +import { CheckCircle2, CircleAlert, MinusCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +export type AssistantTool = { + name: string; + arguments: Record; + result: unknown; +}; + +export default function AssistantToolResult({ + tool, + content, +}: { + tool?: AssistantTool; + content: string; +}) { + const { t } = useTranslation(); + const result = tool?.result; + const data = + result && typeof result === 'object' && !Array.isArray(result) + ? (result as Record) + : {}; + const failed = !!data.error; + const denied = data.status === 'denied'; + const partial = !!data.truncated; + const Icon = + failed || partial ? CircleAlert : denied ? MinusCircle : CheckCircle2; + const items = Array.isArray(result) + ? result + : Array.isArray(data.items) + ? data.items + : null; + const total = typeof data.total === 'number' ? data.total : items?.length; + const kind = tool?.arguments.kind; + const label = + tool?.name === 'list_resources' && typeof kind === 'string' + ? t(`assistant.resources.${kind}`, { defaultValue: kind }) + : t(`assistant.operations.${tool?.name}`, { + defaultValue: t('assistant.toolResult'), + }); + const status = failed + ? 'failed' + : denied + ? 'denied' + : partial + ? 'partial' + : 'completed'; + const url = + typeof data.url === 'string' && + /^\/home\/(pipelines|knowledge)\?id=[\w-]+$/.test(data.url) + ? data.url + : null; + const name = + typeof data.name === 'string' + ? data.name + : typeof tool?.arguments.name === 'string' + ? tool.arguments.name + : null; + + return ( +
+
+ + {label} + + {tool && t(`assistant.${status}`)} + +
+ {failed ? ( +

{t('assistant.operationFailed')}

+ ) : denied ? ( +

+ {t('assistant.operationDenied')} +

+ ) : partial ? ( +

{t('assistant.partial')}

+ ) : ( + <> + {total !== undefined && ( +

{t('assistant.found', { count: total })}

+ )} + {name &&

{name}

} + {items && ( +
    + {items.slice(0, 6).map((item: unknown, index: number) => { + const entry = + item && typeof item === 'object' + ? (item as Record) + : {}; + return ( +
  • + {String(entry.name || entry.uuid || '—')} +
  • + ); + })} +
+ )} + {url && ( + + {t('assistant.openResource')} + + )} + + )} +
+ {t('assistant.details')} +
+          {tool ? JSON.stringify(tool.result, null, 2) : content}
+        
+
+
+ ); +} diff --git a/web/src/app/home/components/WorkspaceAssistant.tsx b/web/src/app/home/components/WorkspaceAssistant.tsx index 42242e20f..1ea0b8d40 100644 --- a/web/src/app/home/components/WorkspaceAssistant.tsx +++ b/web/src/app/home/components/WorkspaceAssistant.tsx @@ -5,6 +5,9 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { backendClient, useCurrentWorkspace, userInfo } from '@/app/infra/http'; import { Button } from '@/components/ui/button'; +import DynamicFormItemComponent from './dynamic-form/DynamicFormItemComponent'; +import { DynamicFormItemType } from '@/app/infra/entities/form/dynamic'; +import AssistantToolResult, { AssistantTool } from './AssistantToolResult'; import { Popover, PopoverContent, @@ -15,10 +18,11 @@ type Conversation = { uuid: string; revision: number; status: 'ready' | 'running' | 'approval' | 'failed'; - messages: { role: string; content: string }[]; + messages: { role: string; content: string; tool?: AssistantTool }[]; pending: { name: string; arguments: Record }[]; error: string | null; model_name: string | null; + model_uuid: string | null; }; export default function WorkspaceAssistant() { @@ -42,11 +46,19 @@ function AssistantPanel({ storageKey }: { storageKey: string }) { const [open, setOpen] = useState(false); const [conversation, setConversation] = useState(null); const [text, setText] = useState(''); - const [busy, setBusy] = useState(false); + const [sending, setBusy] = useState(false); + const [loading, setLoading] = useState(false); + const busy = sending || loading; const [error, setError] = useState(false); + const [modelUuid, setModelUuid] = useState(''); + const [pendingText, setPendingText] = useState(null); const controller = useRef(new AbortController()); const end = useRef(null); + useEffect(() => { + if (conversation?.model_uuid) setModelUuid(conversation.model_uuid); + }, [conversation?.model_uuid]); + useEffect(() => { const abort = new AbortController(); controller.current = abort; @@ -59,7 +71,7 @@ function AssistantPanel({ storageKey }: { storageKey: string }) { const id = localStorage.getItem(storageKey); if (!id) return; let active = true; - setBusy(true); + setLoading(true); backendClient .request({ method: 'GET', @@ -76,11 +88,11 @@ function AssistantPanel({ storageKey }: { storageKey: string }) { } }) .finally(() => { - if (active) setBusy(false); + if (active) setLoading(false); }); return () => { active = false; - setBusy(false); + setLoading(false); }; // Load only when opening; turn requests own subsequent state updates. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -88,10 +100,21 @@ function AssistantPanel({ storageKey }: { storageKey: string }) { useEffect(() => { end.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - }, [conversation, busy]); + }, [conversation, busy, pendingText]); async function submit(approved?: boolean) { - if (busy || (approved === undefined && !text.trim())) return; + if ( + busy || + (approved === undefined && + (!text.trim() || (conversation && conversation.status !== 'ready'))) + ) + return; + const sentText = approved === undefined ? text.trim() : null; + const sentRevision = conversation?.revision ?? 0; + if (sentText) { + setPendingText(sentText); + setText(''); + } setBusy(true); setError(false); try { @@ -110,13 +133,18 @@ function AssistantPanel({ storageKey }: { storageKey: string }) { url: `/api/v1/assistant/conversations/${current.uuid}/turn`, data: { revision: current.revision, - ...(approved === undefined ? { text: text.trim() } : { approved }), + ...(approved === undefined + ? { + text: sentText, + ...(modelUuid ? { model_uuid: modelUuid } : {}), + } + : { approved }), }, timeout: 130000, signal: controller.current.signal, }); setConversation(updated); - if (approved === undefined) setText(''); + if (sentText) setPendingText(null); } catch { setError(true); // A lost response may already have executed a write. Refresh, never replay. @@ -129,6 +157,15 @@ function AssistantPanel({ storageKey }: { storageKey: string }) { signal: controller.current.signal, }); setConversation(latest); + if ( + sentText && + latest.revision > sentRevision && + latest.messages.some( + (message) => + message.role === 'user' && message.content === sentText, + ) + ) + setPendingText(null); } catch { /* Keep the error visible; do not retry a turn. */ } @@ -143,6 +180,7 @@ function AssistantPanel({ storageKey }: { storageKey: string }) { setConversation(null); setText(''); setError(false); + setPendingText(null); } return ( @@ -169,7 +207,7 @@ function AssistantPanel({ storageKey }: { storageKey: string }) {

{t('assistant.title')}

- {conversation?.model_name || t('assistant.subtitle')} + {t('assistant.subtitle')}

+ {open && ( +
+ + {t('assistant.modelHint')} + + {}, + ref: () => {}, + disabled: + busy || (!!conversation && conversation.status !== 'ready'), + }} + requiredModelAbility="func_call" + /> +
+ )}
- {!conversation?.messages.length && ( + {!conversation?.messages.length && !pendingText && ( <>

{t('assistant.welcome')} @@ -214,17 +279,11 @@ function AssistantPanel({ storageKey }: { storageKey: string }) { )} {conversation?.messages.map((message, index) => message.role === 'tool' ? ( -

- - {t('assistant.toolResult')} - -
-                    {message.content}
-                  
-
+ tool={message.tool} + content={message.content} + /> ) : (
), )} + {pendingText && ( +
+ {pendingText} + {error && ( +

+ {t('assistant.sendUnconfirmed')} +

+ )} +
+ )} {conversation?.status === 'approval' && (

{t('assistant.review')}

@@ -313,11 +382,20 @@ function AssistantPanel({ storageKey }: { storageKey: string }) { onChange={(event) => setText(event.target.value)} maxLength={8000} rows={2} - disabled={ - busy || (!!conversation && conversation.status !== 'ready') - } aria-label={t('assistant.placeholder')} - placeholder={t('assistant.placeholder')} + placeholder={t( + busy ? 'assistant.draftPlaceholder' : 'assistant.placeholder', + )} + onKeyDown={(event) => { + if ( + event.key === 'Enter' && + !event.shiftKey && + !event.nativeEvent.isComposing + ) { + event.preventDefault(); + void submit(); + } + }} className="min-w-0 flex-1 resize-none rounded-lg border bg-background p-2 text-sm focus-visible:outline-2 focus-visible:outline-primary" />