new feature: frontend with separated responses

This commit is contained in:
2026-09-11 11:59:46 -03:00
parent e882afde25
commit 7a22b7af11
18 changed files with 567 additions and 33 deletions

View File

@@ -358,30 +358,38 @@ class LoopRail(Guardrail):
@staticmethod
def _transaction_status(ctx: dict[str, Any]) -> str:
status = str(ctx.get("transaction_status") or "").strip().upper()
if status:
return status
nonterminal = {
"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION", "EXECUTING",
"PAUSED", "WAITING_INPUT",
}
# A queued multi-intent transaction may be promoted immediately after
# the previous one completed. Prefer the newest live MCP evidence over
# a stale top-level COMPLETED status left by checkpoint merge order.
for result in reversed(list(ctx.get("mcp_results") or [])):
if isinstance(result, dict):
result_status = str(result.get("transaction_status") or "").strip().upper()
if result_status in nonterminal:
return result_status
active_transaction = ctx.get("active_transaction")
if isinstance(active_transaction, dict):
return str(active_transaction.get("status") or "").strip().upper()
return ""
active_status = str(active_transaction.get("status") or "").strip().upper()
if active_status in nonterminal:
return active_status
return str(ctx.get("transaction_status") or "").strip().upper()
async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision:
ctx = _ctx(context)
transaction_status = self._transaction_status(ctx)
# A short confirmation (for example ``sim``/``não``) may legitimately
# appear several times in the same session because each transaction has
# its own confirmation boundary. VLOOP protects against conversational
# repetition; it must not consume the input that belongs to an active
# transaction awaiting confirmation. The transaction runtime/classifier
# remains responsible for deciding whether the utterance is actually a
# valid confirm/reject response.
if transaction_status == "AWAITING_CONFIRMATION":
# Parameter values and short confirmations may legitimately repeat in
# the same session. VLOOP must not consume input owned by the active
# transaction contract; extraction/confirmation remains responsible
# for deciding whether the value is valid.
if transaction_status in {"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION"}:
return RailDecision(
code=self.code,
allowed=True,
reason="continuidade_transacional:AWAITING_CONFIRMATION",
reason=f"continuidade_transacional:{transaction_status}",
sanitized_text=text,
metadata={
"history_window": len(list(ctx.get("history_texts") or [])[-6:]),

View File

@@ -69,17 +69,30 @@ class MultiIntentPlanner:
def _intent_for_clause(self, clause: str) -> IntentDefinition | None:
normalized = self._normalize(clause)
clause_tokens = set(re.findall(r"[\w]+", normalized, flags=re.UNICODE))
ordered_clause_tokens = re.findall(r"[\w]+", normalized, flags=re.UNICODE)
clause_tokens = set(ordered_clause_tokens)
matches: list[tuple[int, int, IntentDefinition]] = []
for intent in self.intents:
for keyword in intent.keywords:
token = self._normalize(keyword)
keyword_tokens = re.findall(r"[\w]+", token, flags=re.UNICODE)
# Expressões configuradas representam conceitos, não somente
# substrings literais. Preserve a ordem dos termos e aceite
# qualificadores intermediários: "cancelar pedido" também
# reconhece "cancelar meu pedido", sem enumerar variações no YAML.
cursor = 0
ordered_match = bool(keyword_tokens)
for keyword_token in keyword_tokens:
try:
cursor = ordered_clause_tokens.index(keyword_token, cursor) + 1
except ValueError:
ordered_match = False
break
matched = bool(
keyword_tokens
and (
(len(keyword_tokens) == 1 and keyword_tokens[0] in clause_tokens)
or (len(keyword_tokens) > 1 and token in normalized)
or (len(keyword_tokens) > 1 and ordered_match)
)
)
if matched:
@@ -146,7 +159,7 @@ class MultiIntentPlanner:
if len(operations) < 2:
return None
transactional = [item for item in operations if self._is_transactional(item)]
if len(transactional) == 1:
if transactional:
primary_operation = transactional[0]
operations.sort(key=lambda item: item is not primary_operation)
for operation in operations[1:]:

View File

@@ -16,6 +16,29 @@ class PendingTopicDrain:
handled_topics: list[dict[str, Any]]
mcp_results: list[dict[str, Any]]
rag_results: list[dict[str, Any]]
state_patch: dict[str, Any]
_LIVE_TRANSACTION_STATUSES = {
"COLLECTING_PARAMETERS", "AWAITING_CONFIRMATION", "WAITING_CONFIRMATION",
"PENDING", "RUNNING", "IN_PROGRESS",
}
_TRANSACTION_STATE_KEYS = {
"active_transaction", "transaction_status", "transaction_pre_validation",
"confirmation_required", "confirmation_received", "selected_tool_call",
"pending_tool_call", "pending_tool_clarification", "pending_domain_workflow",
"missing_parameters", "tool_policy_result", "next_state", "workflow_id",
}
def _has_live_transaction(state: dict[str, Any]) -> bool:
active = state.get("active_transaction")
active_status = active.get("status") if isinstance(active, dict) else None
status = str(active_status or state.get("transaction_status") or "").upper()
if status:
return status in _LIVE_TRANSACTION_STATUSES
return bool(state.get("confirmation_required") or state.get("pending_tool_call"))
async def drain_pending_topics(
@@ -34,6 +57,8 @@ async def drain_pending_topics(
combined_mcp_results = list(state.get("mcp_results") or [])
combined_rag_results = list(state.get("rag_results") or [])
secondary_answers: list[str] = []
state_patch: dict[str, Any] = {}
may_promote_deferred = not _has_live_transaction(state)
agent_responses = list(state.get("agent_responses") or [])
if not agent_responses and str(candidate or "").strip():
primary_agent = str(
@@ -53,9 +78,15 @@ async def drain_pending_topics(
})
for topic in topics:
disposition = topic.get("disposition")
promoted = False
if disposition == "defer":
remaining.append(topic)
continue
if not may_promote_deferred:
remaining.append(topic)
continue
topic = {**topic, "disposition": "execute", "status": "pending"}
disposition = "execute"
promoted = True
may_promote_deferred = False
if disposition != "execute":
handled.append({**topic, "status": "completed"})
continue
@@ -123,14 +154,42 @@ async def drain_pending_topics(
if rag_evidence not in combined_rag_results:
combined_rag_results.append(rag_evidence)
secondary_answers.append(answer)
if promoted:
agent_responses = [{**item, "primary": False} for item in agent_responses]
agent_responses.append({
"operation_id": topic.get("operation_id"),
"agent": topic.get("agent"),
"intent": topic.get("intent"),
"answer": answer,
"primary": False,
"primary": promoted,
"status": "completed",
})
if promoted:
for key in _TRANSACTION_STATE_KEYS:
if key in (result or {}):
state_patch[key] = result[key]
state_patch.update({
"route": topic.get("agent"),
"active_agent": topic.get("agent"),
"intent": topic.get("intent"),
"domain": topic.get("domain"),
"mcp_tools": topic.get("tools") or [],
# Replace the completed transaction's continuity snapshot so
# the next reply cannot be routed back to the previous agent.
"route_decision": {
"route": topic.get("agent"),
"agent": topic.get("agent"),
"intent": topic.get("intent"),
"domain": topic.get("domain"),
"mcp_tools": topic.get("tools") or [],
"next_state": (result or {}).get("next_state"),
"metadata": {
"multi_intent_secondary": True,
"promoted_from_pending_topics": True,
"operation_id": topic.get("operation_id"),
},
},
})
handled.append({**topic, "status": "completed"})
secondary_answers.extend(MultiIntentPlanner.public_messages(plan))
@@ -148,4 +207,5 @@ async def drain_pending_topics(
handled_topics=handled,
mcp_results=combined_mcp_results,
rag_results=combined_rag_results,
state_patch=state_patch,
)