Skip to content

Commit 10cb2de

Browse files
authored
feat(ph-ai): create form tool (#42085)
## Problem PostHog AI needs a way to collect structured information from users through multi-question forms. This will be particularly helpful in plan/research mode. ![image.png](https://app.graphite.com/user-attachments/assets/b0ade642-331f-4acb-8bc2-67cf635cd171.png) ![image.png](https://app.graphite.com/user-attachments/assets/7a1edf23-97ce-4358-a86f-e05288fc0057.png) ![image.png](https://app.graphite.com/user-attachments/assets/85697675-61d7-4115-9bc6-15cc6577a655.png) ## Changes - Added a new `create_form` tool that allows PostHog AI to create multi-question forms - Implemented form rendering in the frontend with support for: - Multiple questions with predefined options - Optional custom answer input - Form answers are sent as UI payload - Added feature flag `phai-create-form-tool` to control availability - Runner can now handle form responses and resume the conversation (answers are added to the `create_form`​ tool message response and sent back as `ui_payload`​, so they can populate the frontend components on reload) ## How did you test this code? New stories, new tests
1 parent a5c6eac commit 10cb2de

37 files changed

+1793
-43
lines changed

ee/hogai/chat_agent/mode_manager.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
)
2929
from ee.hogai.context import AssistantContextManager
3030
from ee.hogai.core.agent_modes.factory import AgentModeDefinition
31-
from ee.hogai.core.agent_modes.feature_flags import has_agent_modes_feature_flag
3231
from ee.hogai.core.agent_modes.mode_manager import AgentModeManager
3332
from ee.hogai.core.agent_modes.presets.product_analytics import product_analytics_agent
3433
from ee.hogai.core.agent_modes.presets.session_replay import session_replay_agent
@@ -39,7 +38,8 @@
3938
from ee.hogai.core.shared_prompts import CORE_MEMORY_PROMPT
4039
from ee.hogai.registry import get_contextual_tool_class
4140
from ee.hogai.tool import MaxTool
42-
from ee.hogai.tools import ReadDataTool, ReadTaxonomyTool, SearchTool, SwitchModeTool, TodoWriteTool
41+
from ee.hogai.tools import CreateFormTool, ReadDataTool, ReadTaxonomyTool, SearchTool, SwitchModeTool, TodoWriteTool
42+
from ee.hogai.utils.feature_flags import has_agent_modes_feature_flag, has_create_form_tool_feature_flag
4343
from ee.hogai.utils.prompt import format_prompt_string
4444
from ee.hogai.utils.types.base import AssistantState, NodePath
4545

@@ -63,7 +63,10 @@
6363
class ChatAgentToolkit(AgentToolkit):
6464
@property
6565
def tools(self) -> list[type["MaxTool"]]:
66-
return DEFAULT_TOOLS if has_agent_modes_feature_flag(self._team, self._user) else LEGACY_DEFAULT_TOOLS
66+
tools = list(DEFAULT_TOOLS if has_agent_modes_feature_flag(self._team, self._user) else LEGACY_DEFAULT_TOOLS)
67+
if has_create_form_tool_feature_flag(self._team, self._user):
68+
tools.append(CreateFormTool)
69+
return tools
6770

6871

6972
class ChatAgentToolkitManager(AgentToolkitManager):

ee/hogai/core/agent_modes/executables.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
ToolMessage as LangchainToolMessage,
1414
)
1515
from langchain_core.runnables import RunnableConfig
16+
from langgraph.errors import NodeInterrupt
1617
from langgraph.types import Send
1718
from posthoganalytics import capture_exception
1819
from pydantic import ValidationError
@@ -29,7 +30,6 @@
2930
from posthog.event_usage import groups
3031
from posthog.models import Team, User
3132

32-
from ee.hogai.core.agent_modes.feature_flags import has_agent_modes_feature_flag
3333
from ee.hogai.core.agent_modes.prompt_builder import AgentPromptBuilder
3434
from ee.hogai.core.agent_modes.prompts import (
3535
ROOT_CONVERSATION_SUMMARY_PROMPT,
@@ -43,6 +43,7 @@
4343
from ee.hogai.tool_errors import MaxToolError
4444
from ee.hogai.utils.anthropic import add_cache_control, convert_to_anthropic_messages
4545
from ee.hogai.utils.conversation_summarizer import AnthropicConversationSummarizer
46+
from ee.hogai.utils.feature_flags import has_agent_modes_feature_flag
4647
from ee.hogai.utils.helpers import convert_tool_messages_to_dict, normalize_ai_message
4748
from ee.hogai.utils.types import (
4849
AssistantMessageUnion,
@@ -419,6 +420,9 @@ async def arun(self, state: AssistantState, config: RunnableConfig) -> PartialAs
419420
)
420421
],
421422
)
423+
except NodeInterrupt:
424+
# Let NodeInterrupt propagate to the graph engine for tool interrupts
425+
raise
422426
except Exception as e:
423427
logger.exception("Error calling tool", extra={"tool_name": tool_call.name, "error": str(e)})
424428
capture_exception(

ee/hogai/core/agent_modes/feature_flags.py

Lines changed: 0 additions & 13 deletions
This file was deleted.

ee/hogai/core/agent_modes/mode_manager.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,9 @@
99
from ee.hogai.core.agent_modes.prompt_builder import AgentPromptBuilder
1010
from ee.hogai.core.agent_modes.toolkit import AgentToolkit, AgentToolkitManager
1111
from ee.hogai.core.mixins import AssistantContextMixin
12+
from ee.hogai.utils.feature_flags import has_agent_modes_feature_flag
1213
from ee.hogai.utils.types.base import NodePath
1314

14-
from .feature_flags import has_agent_modes_feature_flag
15-
1615
if TYPE_CHECKING:
1716
from .executables import AgentExecutable, AgentToolsExecutable
1817
from .factory import AgentModeDefinition

ee/hogai/core/runner.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
AssistantEventType,
2020
AssistantGenerationStatusEvent,
2121
AssistantMessage,
22+
AssistantToolCallMessage,
2223
AssistantUpdateEvent,
2324
FailureMessage,
2425
HumanMessage,
@@ -36,7 +37,7 @@
3637
from ee.hogai.core.stream_processor import AssistantStreamProcessorProtocol
3738
from ee.hogai.utils.exceptions import LLM_API_EXCEPTIONS, LLM_PROVIDER_ERROR_COUNTER, GenerationCanceled
3839
from ee.hogai.utils.feature_flags import is_privacy_mode_enabled
39-
from ee.hogai.utils.helpers import extract_stream_update
40+
from ee.hogai.utils.helpers import extract_stream_update, find_last_message_of_type
4041
from ee.hogai.utils.state import validate_state_update
4142
from ee.hogai.utils.types.base import (
4243
AssistantDispatcherEvent,
@@ -62,7 +63,7 @@ class BaseAgentRunner(ABC):
6263
_contextual_tools: dict[str, Any]
6364
_conversation: Conversation
6465
_session_id: Optional[str]
65-
_latest_message: Optional[HumanMessage]
66+
_latest_message: Optional[HumanMessage | AssistantToolCallMessage]
6667
_state: Optional[AssistantMaxGraphState]
6768
_callback_handlers: list[BaseCallbackHandler]
6869
_trace_id: Optional[str | UUID]
@@ -218,6 +219,8 @@ async def astream(
218219
interrupt_messages = []
219220
for task in state.tasks:
220221
for interrupt in task.interrupts:
222+
if interrupt.value is None:
223+
continue # Skip None interrupts (used by create_form)
221224
interrupt_message = (
222225
AssistantMessage(content=interrupt.value, id=str(uuid4()))
223226
if isinstance(interrupt.value, str)
@@ -309,6 +312,10 @@ async def _init_or_update_state(self):
309312
saved_state = validate_state_update(snapshot.values, self._state_type)
310313
last_recorded_dt = saved_state.start_dt
311314

315+
# When resuming after a create_form interrupt, create the tool call response message
316+
if form_response_message := self._get_form_response_message(saved_state):
317+
self._latest_message = form_response_message
318+
312319
# Add existing ids to streamed messages, so we don't send the messages again.
313320
for message in saved_state.messages:
314321
if message.id is not None:
@@ -406,3 +413,41 @@ def _capture_exception(self, e: Exception):
406413
"$groups": event_usage.groups(team=self._team),
407414
},
408415
)
416+
417+
def _get_form_response_message(self, saved_state: AssistantMaxGraphState) -> AssistantToolCallMessage | None:
418+
"""
419+
When resuming after a create_form tool call (which raises NodeInterrupt(None)),
420+
create an AssistantToolCallMessage with the user's response content and parsed answers in ui_payload.
421+
"""
422+
if not saved_state.messages or not self._latest_message:
423+
return None
424+
425+
# Form responses must come from a HumanMessage
426+
if not isinstance(self._latest_message, HumanMessage):
427+
return None
428+
429+
# Check if we have form answers in the ui_context
430+
if not self._latest_message.ui_context or not self._latest_message.ui_context.form_answers:
431+
return None
432+
433+
# Find the last assistant message with tool calls
434+
last_assistant_message = find_last_message_of_type(saved_state.messages, AssistantMessage)
435+
if not last_assistant_message or not last_assistant_message.tool_calls:
436+
return None
437+
438+
# Find the create_form tool call
439+
create_form_tool_call = next(
440+
(tc for tc in last_assistant_message.tool_calls if tc.name == "create_form"),
441+
None,
442+
)
443+
if not create_form_tool_call:
444+
return None
445+
446+
answers = self._latest_message.ui_context.form_answers
447+
448+
return AssistantToolCallMessage(
449+
content=self._latest_message.content or "",
450+
id=str(uuid4()),
451+
tool_call_id=create_form_tool_call.id,
452+
ui_payload={"create_form": {"answers": answers}},
453+
)
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
from posthog.test.base import BaseTest
2+
3+
from posthog.schema import AssistantMessage, AssistantToolCall, AssistantToolCallMessage, HumanMessage, MaxUIContext
4+
5+
from ee.hogai.core.runner import BaseAgentRunner
6+
from ee.hogai.utils.types import AssistantState
7+
8+
9+
class MockAgentRunner(BaseAgentRunner):
10+
"""Minimal concrete implementation for testing BaseAgentRunner methods."""
11+
12+
def __init__(self, team, user, latest_message=None):
13+
self._team = team
14+
self._user = user
15+
self._latest_message = latest_message
16+
17+
def get_initial_state(self) -> AssistantState:
18+
return AssistantState(messages=[])
19+
20+
def get_resumed_state(self):
21+
return {}
22+
23+
24+
class TestGetFormResponseMessage(BaseTest):
25+
def setUp(self):
26+
super().setUp()
27+
28+
def _create_runner_with_message(self, latest_message):
29+
return MockAgentRunner(team=self.team, user=self.user, latest_message=latest_message)
30+
31+
def test_returns_none_when_no_messages(self):
32+
runner = self._create_runner_with_message(HumanMessage(content="Test"))
33+
saved_state = AssistantState(messages=[])
34+
35+
result = runner._get_form_response_message(saved_state)
36+
37+
self.assertIsNone(result)
38+
39+
def test_returns_none_when_no_latest_message(self):
40+
runner = self._create_runner_with_message(None)
41+
saved_state = AssistantState(messages=[AssistantToolCallMessage(content="test", tool_call_id="tc1")])
42+
43+
result = runner._get_form_response_message(saved_state)
44+
45+
self.assertIsNone(result)
46+
47+
def test_returns_none_when_latest_message_is_not_human_message(self):
48+
runner = self._create_runner_with_message(
49+
AssistantToolCallMessage(content="Tool response", tool_call_id="some-id")
50+
)
51+
assistant_message = AssistantMessage(
52+
content="Please answer:",
53+
tool_calls=[
54+
AssistantToolCall(
55+
id="create_form_tc_1",
56+
name="create_form",
57+
args={"questions": [{"id": "q1", "question": "Question 1"}]},
58+
type="tool_call",
59+
)
60+
],
61+
)
62+
saved_state = AssistantState(messages=[assistant_message])
63+
64+
result = runner._get_form_response_message(saved_state)
65+
66+
self.assertIsNone(result)
67+
68+
def test_returns_none_when_no_form_answers_in_ui_context(self):
69+
runner = self._create_runner_with_message(HumanMessage(content="My answer"))
70+
assistant_message = AssistantMessage(
71+
content="test",
72+
tool_calls=[AssistantToolCall(id="tc1", name="create_form", args={}, type="tool_call")],
73+
)
74+
saved_state = AssistantState(messages=[assistant_message])
75+
76+
result = runner._get_form_response_message(saved_state)
77+
78+
self.assertIsNone(result)
79+
80+
def test_returns_none_when_no_create_form_tool_call(self):
81+
runner = self._create_runner_with_message(
82+
HumanMessage(content="My answer", ui_context=MaxUIContext(form_answers={"q1": "answer"}))
83+
)
84+
assistant_message = AssistantMessage(
85+
content="test",
86+
tool_calls=[AssistantToolCall(id="tc1", name="other_tool", args={}, type="tool_call")],
87+
)
88+
saved_state = AssistantState(messages=[assistant_message])
89+
90+
result = runner._get_form_response_message(saved_state)
91+
92+
self.assertIsNone(result)
93+
94+
def test_creates_tool_call_message_with_form_answers(self):
95+
user_content = "What is your name: John\nWhat is your role: Engineer"
96+
message = HumanMessage(
97+
content=user_content, ui_context=MaxUIContext(form_answers={"name": "John", "role": "Engineer"})
98+
)
99+
runner = self._create_runner_with_message(message)
100+
101+
tool_call_id = "create_form_tc_1"
102+
assistant_message = AssistantMessage(
103+
content="Please answer these questions:",
104+
tool_calls=[
105+
AssistantToolCall(
106+
id=tool_call_id,
107+
name="create_form",
108+
args={
109+
"questions": [
110+
{"id": "name", "question": "What is your name"},
111+
{"id": "role", "question": "What is your role"},
112+
]
113+
},
114+
type="tool_call",
115+
)
116+
],
117+
)
118+
saved_state = AssistantState(messages=[assistant_message])
119+
120+
result = runner._get_form_response_message(saved_state)
121+
122+
self.assertIsNotNone(result)
123+
self.assertIsInstance(result, AssistantToolCallMessage)
124+
assert isinstance(result, AssistantToolCallMessage)
125+
self.assertEqual(result.content, user_content)
126+
self.assertEqual(result.tool_call_id, tool_call_id)
127+
self.assertIsNotNone(result.ui_payload)
128+
assert result.ui_payload is not None
129+
self.assertIn("create_form", result.ui_payload)
130+
self.assertEqual(
131+
result.ui_payload["create_form"]["answers"],
132+
{"name": "John", "role": "Engineer"},
133+
)
134+
135+
def test_builds_correct_ui_payload_structure(self):
136+
user_content = "Pick a color: Blue"
137+
message = HumanMessage(content=user_content, ui_context=MaxUIContext(form_answers={"color": "Blue"}))
138+
runner = self._create_runner_with_message(message)
139+
140+
tool_call_id = "form_tc_123"
141+
assistant_message = AssistantMessage(
142+
content="Choose your favorite color:",
143+
tool_calls=[
144+
AssistantToolCall(
145+
id=tool_call_id,
146+
name="create_form",
147+
args={
148+
"questions": [
149+
{"id": "color", "question": "Pick a color"},
150+
]
151+
},
152+
type="tool_call",
153+
)
154+
],
155+
)
156+
saved_state = AssistantState(messages=[assistant_message])
157+
158+
result = runner._get_form_response_message(saved_state)
159+
160+
self.assertIsNotNone(result)
161+
assert isinstance(result, AssistantToolCallMessage)
162+
assert result.ui_payload is not None
163+
self.assertEqual(result.ui_payload, {"create_form": {"answers": {"color": "Blue"}}})
164+
165+
def test_returns_none_with_empty_form_answers_dict(self):
166+
user_content = "What is your name: John"
167+
ui_context = MaxUIContext(form_answers={}) # Empty dict
168+
runner = self._create_runner_with_message(HumanMessage(content=user_content, ui_context=ui_context))
169+
170+
tool_call_id = "create_form_tc_1"
171+
assistant_message = AssistantMessage(
172+
content="Please answer:",
173+
tool_calls=[
174+
AssistantToolCall(
175+
id=tool_call_id,
176+
name="create_form",
177+
args={"questions": [{"id": "name", "question": "What is your name"}]},
178+
type="tool_call",
179+
)
180+
],
181+
)
182+
saved_state = AssistantState(messages=[assistant_message])
183+
184+
result = runner._get_form_response_message(saved_state)
185+
186+
self.assertIsNone(result)

ee/hogai/tools/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from .create_and_query_insight import CreateAndQueryInsightTool, CreateAndQueryInsightToolArgs
22
from .create_dashboard import CreateDashboardTool, CreateDashboardToolArgs
3+
from .create_form import CreateFormTool, CreateFormToolArgs
34
from .create_insight import CreateInsightTool, CreateInsightToolArgs
45
from .execute_sql.tool import ExecuteSQLTool, ExecuteSQLToolArgs
56
from .read_data import ReadDataTool, ReadDataToolArgs
@@ -14,6 +15,8 @@
1415
"CreateAndQueryInsightToolArgs",
1516
"CreateDashboardTool",
1617
"CreateDashboardToolArgs",
18+
"CreateFormTool",
19+
"CreateFormToolArgs",
1720
"ReadDataTool",
1821
"ReadDataToolArgs",
1922
"ReadTaxonomyTool",

0 commit comments

Comments
 (0)