Skip to content

Commit 43ff151

Browse files
Feat/typebot interactive buttons (#12)
* feat: estabiliza integración inicial con Typebot * feat(typebot): fix choice input detection and complete A2A structured pipeline - typebot_service: use substring 'choice' check in _extract_structured_input to handle ChoiceInput, choice input, multipleChoiceInput (Typebot v6 types) - typebot_service: add _parse_select_input normalizing items from content/ title/label/text fields across Typebot API versions - typebot_service: add _find_items_list recursive deep search for items array - typebot_service: add _parse_rating_input for numeric rating blocks - typebot_service: _build_structured_response returns {text, structured} dict - typebot_service: _format_messages adds plainText fallback, improved richText node handling (li, p, ul, ol, a with href) - external_agent: detect dict response, emit EVO_STRUCTURED: prefixed ADK part - a2a_routes: extract_structured_from_message_history scans ADK history for EVO_STRUCTURED part; build_a2a_artifacts includes select artifact when present * refactor(typebot): move is_only_registering to __init__ attribute The inline walrus-operator assignment in start_session was hard to follow. Compute the flag once during __init__ as self.is_only_registering and reference it directly in the payload, eliminating the local variable. --------- Co-authored-by: Milton Sosa <milton.sosa.22@gmail.com>
1 parent 7c877f9 commit 43ff151

4 files changed

Lines changed: 479 additions & 71 deletions

File tree

src/api/a2a_routes.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@ def create_task_response(
356356
task_id: str,
357357
context_id: str,
358358
final_response: str,
359+
artifacts: Optional[List[Dict[str, Any]]] = None,
359360
conversation_history: Optional[List[Dict[str, Any]]] = None,
360361
current_user_message: Optional[Dict[str, Any]] = None,
361362
) -> Dict[str, Any]:
@@ -366,7 +367,7 @@ def create_task_response(
366367
)
367368

368369
# Create main response artifact (only the agent's response)
369-
artifacts = [
370+
artifacts = artifacts or [
370371
{
371372
"artifactId": str(uuid.uuid4()),
372373
"parts": [{"type": "text", "text": final_response}],
@@ -524,6 +525,8 @@ async def extract_conversation_history(
524525
if isinstance(part, dict) and part.get("text"):
525526
role = "user" if event_dict.get("author") == "user" else "agent"
526527
text_content = part["text"]
528+
if isinstance(text_content, str) and text_content.startswith(STRUCTURED_PART_PREFIX):
529+
continue
527530

528531
# Clean the content to remove JSON artifacts
529532
cleaned_content = clean_message_content(text_content, role)
@@ -782,6 +785,75 @@ def extract_metadata_from_request(params: Dict[str, Any]) -> Dict[str, Any]:
782785
return metadata
783786

784787

788+
STRUCTURED_PART_PREFIX = "EVO_STRUCTURED:"
789+
790+
791+
def extract_structured_from_message_history(
792+
message_history: Optional[List[Dict[str, Any]]],
793+
) -> Optional[Dict[str, Any]]:
794+
if not message_history:
795+
return None
796+
797+
for event in reversed(message_history):
798+
content = event.get("content") if isinstance(event, dict) else None
799+
parts = content.get("parts") if isinstance(content, dict) else None
800+
if not isinstance(parts, list):
801+
continue
802+
803+
for part in parts:
804+
if not isinstance(part, dict):
805+
continue
806+
807+
text = part.get("text")
808+
if not isinstance(text, str) or not text.startswith(STRUCTURED_PART_PREFIX):
809+
continue
810+
811+
raw = text[len(STRUCTURED_PART_PREFIX):].strip()
812+
try:
813+
decoded = json.loads(raw)
814+
return decoded if isinstance(decoded, dict) else None
815+
except Exception:
816+
continue
817+
818+
return None
819+
820+
821+
def build_a2a_artifacts(
822+
final_response: str,
823+
structured: Optional[Dict[str, Any]] = None,
824+
) -> List[Dict[str, Any]]:
825+
artifacts: List[Dict[str, Any]] = [
826+
{
827+
"artifactId": str(uuid.uuid4()),
828+
"parts": [{"type": "text", "text": final_response}],
829+
}
830+
]
831+
832+
if not structured:
833+
return artifacts
834+
835+
input_obj = structured.get("input") if isinstance(structured, dict) else None
836+
if not isinstance(input_obj, dict):
837+
return artifacts
838+
839+
if input_obj.get("type") == "select" and isinstance(input_obj.get("items"), list):
840+
artifacts.append(
841+
{
842+
"artifactId": str(uuid.uuid4()),
843+
"parts": [
844+
{
845+
"type": "select",
846+
"items": input_obj.get("items", []),
847+
"isMultiple": bool(input_obj.get("isMultiple")),
848+
"sourceType": input_obj.get("sourceType"),
849+
}
850+
],
851+
}
852+
)
853+
854+
return artifacts
855+
856+
785857
async def handle_message_send(
786858
agent_id: uuid.UUID, params: Dict[str, Any], request_id: str, request: Request, db: Session
787859
) -> JSONResponse:
@@ -985,11 +1057,15 @@ async def handle_message_send(
9851057
"timestamp": None, # Could add current timestamp
9861058
}
9871059

1060+
structured = extract_structured_from_message_history(result.get("message_history"))
1061+
artifacts = build_a2a_artifacts(final_response, structured)
1062+
9881063
# Create A2A compliant response with history
9891064
task_response = create_task_response(
9901065
task_id,
9911066
context_id,
9921067
final_response,
1068+
artifacts,
9931069
combined_history if combined_history else None,
9941070
current_user_message,
9951071
)

src/services/adk/agents/external_agent.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from sqlalchemy.orm import Session
1010
from typing import AsyncGenerator, Dict, Any
1111
import logging
12+
import json
1213

1314
from src.services.providers import (
1415
FlowiseService,
@@ -33,6 +34,7 @@ class ExternalAgent(BaseAgent):
3334
provider: str
3435
integration_config: Dict[str, Any]
3536
db: Session
37+
provider_service: Any = None
3638

3739
def __init__(
3840
self,
@@ -129,18 +131,28 @@ async def _run_async_impl(
129131

130132
# Send message to provider
131133
try:
132-
response_text = await self.provider_service.send_message(
134+
provider_response = await self.provider_service.send_message(
133135
message=user_message,
134136
session_id=session_id,
135137
context=provider_context,
136138
)
137139

140+
response_text = provider_response
141+
structured = None
142+
if isinstance(provider_response, dict):
143+
response_text = provider_response.get("text", "")
144+
structured = provider_response.get("structured")
145+
146+
parts = [Part(text=str(response_text) if response_text is not None else "")]
147+
if structured is not None:
148+
parts.append(Part(text=f"EVO_STRUCTURED:{json.dumps(structured, ensure_ascii=False)}"))
149+
138150
# Yield response event
139151
yield Event(
140152
author=self.name,
141153
content=Content(
142154
role="agent",
143-
parts=[Part(text=response_text)],
155+
parts=parts,
144156
),
145157
)
146158

@@ -171,12 +183,14 @@ async def _run_async_impl(
171183

172184
def _get_session_id(self, ctx: InvocationContext) -> str:
173185
"""Get or generate session ID from context."""
186+
if ctx.session and ctx.session.id:
187+
return ctx.session.id
188+
174189
if ctx.session and ctx.session.state:
175190
session_id = ctx.session.state.get("session_id")
176191
if session_id:
177192
return session_id
178-
179-
# Generate new session ID
193+
180194
import uuid
181195
return str(uuid.uuid4())
182196

src/services/adk/runners/standard_runner.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -484,14 +484,17 @@ async def run_agent(
484484
)
485485

486486
try:
487-
# Handle both Pydantic v2 (model_dump) and older versions
488-
if hasattr(root_agent.model, "model_dump"):
489-
model_dict = root_agent.model.model_dump()
490-
elif hasattr(root_agent.model, "dict"):
491-
model_dict = root_agent.model.dict()
487+
if not hasattr(root_agent, "model"):
488+
model_str = "external"
492489
else:
493-
model_dict = root_agent.model.__dict__
494-
model_str = model_dict.get("model", str(root_agent.model))
490+
# Handle both Pydantic v2 (model_dump) and older versions
491+
if hasattr(root_agent.model, "model_dump"):
492+
model_dict = root_agent.model.model_dump()
493+
elif hasattr(root_agent.model, "dict"):
494+
model_dict = root_agent.model.dict()
495+
else:
496+
model_dict = root_agent.model.__dict__
497+
model_str = model_dict.get("model", str(root_agent.model))
495498
metrics_data = ExecutionMetricsCreate(
496499
agent_id=uuid.UUID(agent_id),
497500
session_id=adk_session_id,

0 commit comments

Comments
 (0)