MarketerAI Module Architecture - Information Flow
Table of Contents
- Overview
- Conversation Initialization
- AI Response Streaming
- Function Calling
- Subagent Mechanism
- Confirmation System
- Sequence Diagram
- Example Scenarios
Overview
This document describes the detailed information flow from the moment a user sends a message, through AI processing and function calls, to receiving a response.
Conversation Initialization
Scenario 1: New Conversation
Endpoint: POST /api/projects/{project}/agents/{agent}/chat
Request Flow
Frontend sends: POST request with body containing the user message ("Show me top 10 products")
Controller (SembotChatController.create()):
- Starts a database transaction
- Creates a new ChatThread with user_id, project_id, agent_id, and title (truncated to 200 characters)
- Updates LastAgentUsage (for agent last-use statistics)
- Commits the transaction
- Dispatches the ChatStream job to the asynchronous queue
- Returns 200 OK immediately (empty response)
Frontend:
- Receives 200 OK
- Listens on the private WebSocket channel:
App.Chat.{userId}.{projectId} - Waits for events: ThreadStart, StreamMessageChunk, ToolCall, ToolResponse, MessageEnd, ThreadEnd
Key point: The HTTP response is immediate — we do not wait for AI processing. All further communication continues via WebSocket in real time.
Scenario 2: Continuing a Conversation
Endpoint: POST /api/projects/{project}/chat-threads/{thread}
Flow: Identical to Scenario 1, but instead of creating a new ChatThread, we use an existing one. Conversation history is preserved — the AI sees all previous messages, tool calls, and tool responses.
Example: If the user asked "Show top 10 products" in the first message and the AI returned a list, the user can then write "Change labels for first 5" in the next message — the AI knows which products were referenced in the previous response.
AI Response Streaming
Step 1: ChatStream Job Starts
What happens:
- The job is pulled from the Redis queue by a queue worker
- Broadcasts a
ThreadStartevent (frontend shows loading indicator) - Initializes ChatService and ChatMessageRepository
- Checks whether this is a resume after a confirmation (if so → different logic)
- If this is a UserMessage object (predefined message) → generates its content via AI
- Auto-reject pending confirmations: If a pending confirmation exists in this thread or its subthreads, it is automatically rejected and the AI is informed that the user sent a new message
- Creates a USER_MESSAGE in the database
- Calls ChatService.handleStreamedAgent()
Step 2: ChatService Selects API Format
Decision:
- If provider=OpenAI AND config('ai.default_api')='responses' → Responses API
- Otherwise → Completions API
Why it matters: The Responses API is newer (2024), has a better event structure, and supports structured outputs. The Completions API is older but more universal (also works with Groq, OpenRouter).
Step 3: OpenAiService Creates a Stream
What happens:
- Fetches message history from ChatMessageRepository (last N user/assistant/tool_call/tool_response messages)
- Prepends the agent's system prompt
- Fetches available tools (functions from the database + runtime functions like searchKnowledgeBase)
- Moderates the user's last message (checks for hate speech/violence)
- Calls the OpenAI API with stream=true
- Returns a StreamResponse — an iterator that produces chunk by chunk
Step 4: ChatService Processes the Stream
For each chunk:
If it is text (delta):
- Accumulates in a local variable
- Broadcasts
StreamMessageChunkevent (frontend appends to the displayed message)
If it is a tool call delta:
- Accumulates ID, function name, and JSON arguments
- Broadcasts
FunctionGenerationStart(at the beginning) - Broadcasts
FunctionArgumentsChunkfor each fragment of arguments
If finish_reason='stop':
- Saves ASSISTANT_MESSAGE in the database
- Broadcasts
MessageEnd - Ends iteration
If finish_reason='tool_calls':
- Saves ASSISTANT_TOOL_CALL in the database
- Calls handleFunctionCalling()
- If the function returned that another iteration is needed → creates a new stream with tool_response in context
Function Calling
Tool Call Flow
Example: AI calls getTopProducts(limit=10, sortBy="revenue")
handleFunctionCalling receives:
- toolCallId: "call_abc123"
- functionName: "getTopProducts"
- arguments:
Broadcast ToolCall event: Frontend shows "Calling getTopProducts..." with arguments
callChatFunction() decides:
- Is this a runtime function? (isRuntimeFunction("getTopProducts") → false)
- Fetches ChatFunction from the database for this agent
- Does it have a subagent_id? (no)
- Calls executeChatFunctionClass()
executeChatFunctionClass():
- Laravel DI creates an instance of
App\Services\Ai\Chat\ChatFunctions\Products\GetTopProducts - Injects User, Project, Agent via the constructor
- Validates arguments according to the rules() methods (limit: integer|min:1|max:100, sortBy: in:revenue,units,clicks)
- If validation fails → returns "Validation failed: limit must be between 1 and 100"
- If OK → calls handle($validated, $additionalData)
- Laravel DI creates an instance of
GetTopProducts.handle():
- Executes a database query: fetches the project's products, sorts by revenue DESC, limit 10
- Formats the result as text: "Product 1: iPhone 15 Pro, Revenue: $52,450, Units: 124\nProduct 2: ..."
- Optionally adds $additionalData['table_data'] with a table structure for the frontend
- Returns a text response
Save and broadcast:
- Creates a TOOL_RESPONSE message in the database with toolCallId="call_abc123" and the response
- Broadcasts
ToolResponseevent - Frontend can display a table (if additionalData is present)
Next iteration:
- handleFunctionCalling returns true (another iteration is needed)
- handleCompletionsStream creates a new stream
- The AI now sees the tool_response in context and can respond to the user: "Here are the top 10 products by revenue: [formatted results]..."
Subagent Mechanism
When It Is Used
When a function has a subagent_id set — instead of executing PHP code, we delegate to a specialized AI agent.
Example use case: The main agent (General Assistant) has a "manageProducts" function that delegates to the "Product Manager" subagent. The Product Manager has its own system prompt, its own functions (getTopProducts, changeCustomLabel, duplicateProducts), and its own AI model.
Subagent Flow
User → Main Agent: "Manage products: change labels for top 10"
Main Agent calls:
manageProducts(user_request="change labels for top 10")handleSubagentFunction():
- Fetches the subagent (Product Manager) from function.subagent
- Prepares a custom system prompt (if present in function.subagent_system_prompt)
- Prepares the user message with placeholders (function.subagent_user_message → "User wants to: {user_request}")
- Checks the preserve_previous_context parameter
SubagentThreadService:
- If preserve_previous_context=true → looks for an existing subthread
- If found → returns it (continuation with context)
- If not found or preserve=false → creates a new subthread with parent_thread_id
Creating the structure:
- Saves an AGENT_SUBTHREAD message in the parent thread (link to the subthread)
- Broadcasts
AgentSubthreadStart(frontend creates a nested container) - Creates a USER_MESSAGE in the subthread
- Broadcasts
SubthreadUserMessage
Recursion:
- ChatService.handleStreamedAgent() is called RECURSIVELY for the subagent
- The subagent has its own streaming, its own tool calls, and its own events
- All events include subthread_id so the frontend knows where to display them
Subagent works:
- Calls getTopProducts(limit=10) → gets the list
- Calls changeCustomLabel(products=[1,2,3,4,5,6,7,8,9,10], label="Sale")
- STOP — the function requires confirmation!
Pending in subthread:
- Creates PENDING_USER_CONFIRMATION in the subthread
- Broadcasts
ActionRequiresConfirmationwith details - Job ends (return)
User approves:
- Frontend sends POST /confirmations/{id} with approved=true
- Dispatches a new ChatStream job with pendingConfirmation + approved flag
Subthread resumes:
- handleConfirmationResume() executes changeCustomLabel
- Broadcasts
ToolResponsefor the subthread - Subagent streaming continues
- Subagent responds: "Changed custom_label_0 to 'Sale' for 10 products"
- Ends (no more tool calls, finish_reason='stop')
Return to parent:
- continueParentThreads() is called
- createToolResponseInParentThread() — extracts the subagent's last response and saves it as a tool_response in the parent thread
- Continues main agent streaming with tool_response in context
- Main agent responds: "Done! I've managed your products - changed labels for top 10."
Hierarchy:
ChatThread (id=1, main thread)
├─ USER_MESSAGE: "Manage products..."
├─ ASSISTANT_TOOL_CALL: manageProducts()
├─ AGENT_SUBTHREAD: → subthread_id=2
│
└─ ChatThread (id=2, subthread, parent_thread_id=1)
├─ USER_MESSAGE: "User wants to: change labels for top 10"
├─ ASSISTANT_MESSAGE: "Let me get top products..."
├─ ASSISTANT_TOOL_CALL: getTopProducts()
├─ TOOL_RESPONSE: "Product 1: ..., Product 2: ..."
├─ ASSISTANT_TOOL_CALL: changeCustomLabel()
├─ PENDING_USER_CONFIRMATION: [PAUSE]
├─ TOOL_RESPONSE: "Changed labels for 10 products"
└─ ASSISTANT_MESSAGE: "Changed custom_label_0 to 'Sale' for 10 products"
├─ TOOL_RESPONSE: "Changed custom_label_0 to 'Sale' for 10 products" (from subthread)
└─ ASSISTANT_MESSAGE: "Done! I've managed your products..."Confirmation System
Trigger Confirmation
Condition: ChatFunction.requires_user_confirmation = true
Examples: changeCustomLabel, editCampaignStatus, addNegativeKeywords, createTask
Flow
- AI calls a function that requires confirmation
- handleFunctionCalling() detects: function.requires_user_confirmation === true
- Creates PENDING_USER_CONFIRMATION message:
- Contains: tool_call_id, function_name, arguments, thread_context (thread_id, parent_thread_id)
- Broadcasts ActionRequiresConfirmation:
- Frontend shows a modal: "Change custom_label_0 to 'Sale' for 5 products?" [Cancel] [Confirm]
- Job ends — return (function is not executed, streaming does not continue)
User Decision
Cancel:
- POST /confirmations/{id} with approved=false
- New job with pendingConfirmation + approved=false
- handleConfirmationResume() creates a tool_response: "User declined this action."
- Broadcasts
ActionRejected - Streaming continues — the AI knows the user declined and responds e.g. "Okay, I won't change the labels."
Confirm:
- POST /confirmations/{id} with approved=true
- New job with pendingConfirmation + approved=true
- handleConfirmationResume() EXECUTES the function
- Broadcasts
ActionConfirmed - Broadcasts
ToolResponsewith the result - Streaming continues — the AI sees the result and responds e.g. "Done! Changed labels for 5 products."
Auto-Reject
Situation: The user has a pending confirmation but sends a new message instead of confirming or rejecting.
Resolution:
- ChatStream.handle() checks getActivePendingConfirmationInThreadTree() at startup
- If found → automatically rejects (declinePendingAction)
- Creates a tool_response: "Previous action was automatically declined because user sent a new message."
- Broadcasts
ActionRejectedwith automaticRejection=true - Continues with the current thread (the one with the old pending) — the AI is informed that the user declined
- Then processes the new user message normally
Why: The user changed their mind — instead of confirming "change labels" they sent "show me analytics." We do not leave hanging confirmations.
Sequence Diagram
Full Conversation with Subagent and Confirmation
Example Scenarios
Scenario 1: Simple Function Without Confirmation
User: "Show me top 5 products"
Timeline:
- 0ms: ThreadStart
- 50ms: StreamMessageChunk "Let"
- 55ms: StreamMessageChunk " me"
- 100ms: FunctionGenerationStart
- 150ms: FunctionArgumentsChunk (JSON fragments)
- 200ms: ToolCall getTopProducts
- 1500ms: ToolResponse (with product data)
- 1600ms: StreamMessageChunk "Here"
- 1700ms: MessageEnd
- 1750ms: ThreadEnd
- 1800ms: ProposeActions
Result: User sees a list of 5 products + suggestions ("Analyze trends", "Change prices")
Scenario 2: Function With Confirmation
User: "Change custom_label_0 to 'Sale' for products 1, 2, 3"
Timeline:
- 0ms: ThreadStart
- 100ms: FunctionGenerationStart
- 200ms: ToolCall changeCustomLabel
- 250ms: ActionRequiresConfirmation → PAUSE
- [User interaction 5000ms]
- 5000ms: User clicks Confirm
- 5010ms: ThreadStart (resume)
- 5200ms: ToolResponse "Changed labels for 3 products"
- 5300ms: StreamMessageChunk "Done!"
- 5400ms: ThreadEnd
Result: Labels changed after confirmation
Scenario 3: Subagent With Nested Confirmation
User: "Manage products: change label for top 10"
Flow:
- Main thread → calls manageProducts
- Subthread created → Product Manager
- Subagent calls getTopProducts → success
- Subagent calls changeCustomLabel → requires confirmation
- PAUSE in subthread
- User confirms
- Subthread ends
- Parent thread continues
- Main agent responds
Thread Structure:
- Main Thread (id=1)
- Subthread (id=2, parent=1) ← confirmation happened here
- Result saved in both threads
Created: 2025-11-26 Version: 1.0