Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions apps/tanstack-chat-demo/src/components/chat/settings-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ export function SettingsPopover({ settings, onChange, onRefresh }: Props) {
<PopoverContent
align="end"
sideOffset={8}
className="w-[360px] rounded-[16px] border-0 bg-bg-elevated p-0 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.25),0_0_0_0.5px_rgba(0,0,0,0.08)]"
className="max-h-[var(--radix-popover-content-available-height)] w-[360px] overflow-hidden rounded-[16px] border-0 bg-bg-elevated p-0 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.25),0_0_0_0.5px_rgba(0,0,0,0.08)]"
>
<div className="px-4 pt-4 pb-3">
<div className="shrink-0 px-4 pt-4 pb-3">
<h3 className="text-[17px] font-semibold text-label" style={{ letterSpacing: '-0.43px' }}>
Settings
</h3>
Expand All @@ -40,7 +40,7 @@ export function SettingsPopover({ settings, onChange, onRefresh }: Props) {
</p>
</div>

<div className="px-4">
<div className="min-h-0 overflow-y-auto px-4">
<div className="overflow-hidden rounded-[12px] bg-bg-2">
<Field label="OpenAI key">
<Input
Expand Down Expand Up @@ -79,13 +79,13 @@ export function SettingsPopover({ settings, onChange, onRefresh }: Props) {
value={settings.systemPrompt}
onChange={(e) => onChange({ systemPrompt: e.target.value })}
placeholder="Optional instructions for the assistant"
className="mt-1.5 resize-none rounded-[8px] border-0 bg-bg px-2.5 py-2 text-[14px] leading-[1.4] text-label shadow-none placeholder:text-label-3 focus-visible:ring-0"
className="mt-1.5 max-h-[min(40dvh,320px)] resize-y overflow-y-auto rounded-[8px] border-0 bg-bg px-2.5 py-2 text-[14px] leading-[1.4] text-label shadow-none ![field-sizing:fixed] placeholder:text-label-3 focus-visible:ring-0"
style={{ letterSpacing: '-0.15px' }}
/>
</div>
</div>

<div className="mt-4 flex items-center justify-between border-t border-hairline px-4 py-3">
<div className="mt-4 flex shrink-0 items-center justify-between border-t border-hairline px-4 py-3">
<span className="text-[12.5px] text-label-2">Cached in this browser</span>
<button
type="button"
Expand Down
1 change: 1 addition & 0 deletions packages/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@agentic-kit/core": "workspace:*",
"agentic-kit": "workspace:*"
},
"keywords": []
Expand Down
210 changes: 210 additions & 0 deletions packages/agent/src/agent-loop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import {
type AssistantMessage,
type Context,
createToolResultMessage,
type Message,
type StreamOptions,
} from '@agentic-kit/core';
import { stream as defaultStream } from 'agentic-kit';

import type {
AgentEvent,
AgentOptions,
AgentState,
AgentTool,
AgentToolResult,
} from './types.js';
import { validateToolArguments as defaultValidateToolArguments } from './validation.js';

export type AgentEventSink = (event: AgentEvent) => void | Promise<void>;

export type AgentLoopConfig = {
initialMessages?: Message[];
state: AgentState;
streamFn?: AgentOptions['streamFn'];
transformContext?: AgentOptions['transformContext'];
validateToolArguments?: AgentOptions['validateToolArguments'];
signal: AbortSignal;
};

export async function runAgentLoop(config: AgentLoopConfig, emit: AgentEventSink): Promise<Message[]> {
const messages = [...config.state.messages];

await emit({ type: 'agent_start' });

if (config.initialMessages && config.initialMessages.length > 0) {
for (const message of config.initialMessages) {
await emit({ type: 'message_start', message });
messages.push(message);
await emit({ type: 'message_end', message });
}
}

while (true) {
await emit({ type: 'turn_start' });

const assistantMessage = await generateAssistantMessage(config, messages, emit);
messages.push(assistantMessage);
await emit({ type: 'message_end', message: assistantMessage });

if (assistantMessage.stopReason === 'error' || assistantMessage.stopReason === 'aborted') {
await emit({ type: 'turn_end', message: assistantMessage, toolResults: [] });
break;
}

const toolCalls = assistantMessage.content.filter((block) => block.type === 'toolCall');
if (toolCalls.length === 0) {
await emit({ type: 'turn_end', message: assistantMessage, toolResults: [] });
break;
}

const toolResults = await executeToolCalls(config, toolCalls, emit);
for (const toolResult of toolResults) {
await emit({ type: 'message_start', message: toolResult });
messages.push(toolResult);
await emit({ type: 'message_end', message: toolResult });
}

await emit({ type: 'turn_end', message: assistantMessage, toolResults });
}

await emit({ type: 'agent_end', messages });
return messages;
}

async function generateAssistantMessage(
config: AgentLoopConfig,
messages: Message[],
emit: AgentEventSink
): Promise<AssistantMessage> {
const transformedMessages = config.transformContext
? await config.transformContext(messages, config.signal)
: messages;

const context: Context = {
systemPrompt: config.state.systemPrompt,
tools: config.state.tools,
messages: transformedMessages,
};

const streamFn = config.streamFn ?? defaultStream;
const streamResult = streamFn(config.state.model, context, {
...(config.state.streamOptions ?? {}),
signal: config.signal,
} as StreamOptions);

for await (const event of streamResult) {
switch (event.type) {
case 'start':
await emit({ type: 'message_start', message: event.partial });
break;
case 'text_start':
case 'text_delta':
case 'text_end':
case 'thinking_start':
case 'thinking_delta':
case 'thinking_end':
case 'toolcall_start':
case 'toolcall_delta':
case 'toolcall_end':
await emit({
type: 'message_update',
message: event.partial,
assistantMessageEvent: event,
});
break;
case 'done':
case 'error':
break;
}
}

return streamResult.result();
}

async function executeToolCalls(
config: AgentLoopConfig,
toolCalls: Array<Extract<AssistantMessage['content'][number], { type: 'toolCall' }>>,
emit: AgentEventSink
) {
const results = [];

for (const toolCall of toolCalls) {
const tool = config.state.tools.find((candidate) => candidate.name === toolCall.name);
await emit({
type: 'tool_execution_start',
toolCallId: toolCall.id,
toolName: toolCall.name,
args: toolCall.arguments as Record<string, unknown>,
});

let result: AgentToolResult;
let isError = false;

try {
if (!tool) {
throw new Error(`Tool '${toolCall.name}' not found`);
}

const validateToolArguments = config.validateToolArguments ?? defaultValidateToolArguments;
const validatedArgs = validateToolArguments(
tool.parameters,
toolCall.arguments as Record<string, unknown>
);

result = await executeTool(tool, toolCall.id, validatedArgs, config.signal, emit);
} catch (error) {
result = {
content: [
{
type: 'text',
text: error instanceof Error ? error.message : String(error),
},
],
};
isError = true;
}

await emit({
type: 'tool_execution_end',
toolCallId: toolCall.id,
toolName: toolCall.name,
result,
isError,
});

results.push(
createToolResultMessage(toolCall.id, toolCall.name, result.content, isError)
);
}

return results;
}

async function executeTool(
tool: AgentTool,
toolCallId: string,
args: Record<string, unknown>,
signal: AbortSignal,
emit: AgentEventSink
): Promise<AgentToolResult> {
const updateEvents: Promise<void>[] = [];

try {
return await tool.execute(toolCallId, args, signal, (partialResult) => {
updateEvents.push(
Promise.resolve(
emit({
type: 'tool_execution_update',
toolCallId,
toolName: tool.name,
args,
partialResult,
})
)
);
});
} finally {
await Promise.all(updateEvents);
}
}
Loading
Loading