From ddc58f5a01f5b853c5cba0e94916f5a746604f32 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 24 Jun 2026 16:40:30 +0200 Subject: [PATCH] feat(frontend): restore agent playground UI on big-agents --- .../RAG_QA_chatbot/backend/agent_loop.py | 588 +++++++++++++++ .../RAG_QA_chatbot/backend/contract_stream.py | 162 +++++ .../python/RAG_QA_chatbot/backend/main.py | 6 + examples/python/RAG_QA_chatbot/backend/rag.py | 25 +- examples/python/RAG_QA_chatbot/env.example | 11 + .../python/RAG_QA_chatbot/ingest/fix_urls.py | 64 ++ .../python/RAG_QA_chatbot/ingest/loaders.py | 16 +- .../python/RAG_QA_chatbot/ingest/store.py | 98 +-- .../RAG_QA_chatbot/run-agent-chat-slice.sh | 108 +++ .../apps/[app_id]/agent-chat/index.tsx | 3 + web/oss/package.json | 4 + .../AgentChatSlice/AgentChatPanel.tsx | 431 +++++++++++ .../AgentChatSlice/assets/agConfig.ts | 104 +++ .../AgentChatSlice/assets/constants.ts | 29 + .../components/AgentChatSlice/assets/files.ts | 45 ++ .../AgentChatSlice/assets/loadSession.ts | 25 + .../AgentChatSlice/assets/markdown.tsx | 108 +++ .../AgentChatSlice/assets/rewind.ts | 34 + .../AgentChatSlice/assets/toAgentaMessage.ts | 142 ++++ .../components/AgentChatSlice/assets/trace.ts | 64 ++ .../AgentChatSlice/assets/transport.ts | 132 ++++ .../components/AgentChatConversation.tsx | 283 ++++++++ .../components/AgentMessage.tsx | 357 ++++++++++ .../components/SessionHistoryMenu.tsx | 138 ++++ .../components/SessionTabLabel.tsx | 44 ++ .../AgentChatSlice/components/ToolPart.tsx | 183 +++++ .../src/components/AgentChatSlice/index.tsx | 182 +++++ .../AgentChatSlice/state/sessions.ts | 258 +++++++ web/oss/src/components/Layout/Layout.tsx | 10 +- .../src/components/Playground/Playground.tsx | 10 + .../SessionDrawer/assets/utils.ts | 32 +- .../components/SessionHeader/index.tsx | 49 +- .../components/TraceTypeHeader/index.tsx | 17 +- .../components/CreateAppDropdown/index.tsx | 6 + .../modals/CreateAppTypeModal/index.tsx | 6 + .../SessionsTable/assets/sessionCellStore.tsx | 32 + .../components/Cells/DurationCell.tsx | 6 +- .../components/Cells/EndTimeCell.tsx | 6 +- .../components/Cells/FirstInputCell.tsx | 7 +- .../components/Cells/LastOutputCell.tsx | 7 +- .../components/Cells/SessionIdCell.tsx | 5 +- .../components/Cells/StartTimeCell.tsx | 6 +- .../components/Cells/TotalCostCell.tsx | 6 +- .../components/Cells/TotalLatencyCell.tsx | 6 +- .../components/Cells/TotalUsageCell.tsx | 6 +- .../components/Cells/TracesCountCell.tsx | 7 +- .../components/SessionsTable/index.tsx | 80 ++- .../pages/prompts/assets/iconHelpers.tsx | 4 +- web/oss/src/lib/helpers/dynamicEnv.ts | 8 + .../apps/[app_id]/agent-chat/index.tsx | 34 + .../state/newObservability/atoms/queries.ts | 28 +- .../newObservability/selectors/tracing.ts | 27 +- .../src/loadable/controller.ts | 73 +- .../src/workflow/core/schema.ts | 4 + .../src/workflow/state/appUtils.ts | 6 +- .../src/workflow/state/evaluatorUtils.ts | 1 + .../src/workflow/state/helpers.ts | 5 +- .../src/workflow/state/molecule.ts | 14 + .../src/workflow/state/store.ts | 1 + .../unit/derive-workflow-type-agent.test.ts | 55 ++ .../SchemaControls/AgentConfigControl.tsx | 670 ++++++++++++++++++ .../SchemaControls/McpServerItemControl.tsx | 139 ++++ .../SchemaControls/SchemaPropertyRenderer.tsx | 21 + .../src/DrillInView/SchemaControls/index.ts | 15 + .../src/components/ExecutionHeader/index.tsx | 64 +- .../src/components/ExecutionItems/index.tsx | 23 +- .../src/context/PlaygroundUIContext.tsx | 18 + web/packages/agenta-playground/src/index.ts | 6 + .../state/controllers/executionController.ts | 4 + .../src/state/execution/agentRequest.ts | 262 +++++++ .../state/execution/generationSelectors.ts | 15 +- .../src/state/execution/index.ts | 5 + .../src/state/execution/selectors.ts | 61 ++ .../agenta-playground/src/state/index.ts | 2 + .../tests/unit/agentMode.test.ts | 158 +++++ .../tests/unit/agentRequest.test.ts | 290 ++++++++ web/pnpm-lock.yaml | 255 +++++++ 77 files changed, 6049 insertions(+), 167 deletions(-) create mode 100644 examples/python/RAG_QA_chatbot/backend/agent_loop.py create mode 100644 examples/python/RAG_QA_chatbot/backend/contract_stream.py create mode 100644 examples/python/RAG_QA_chatbot/ingest/fix_urls.py create mode 100644 examples/python/RAG_QA_chatbot/run-agent-chat-slice.sh create mode 100644 web/ee/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/agent-chat/index.tsx create mode 100644 web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx create mode 100644 web/oss/src/components/AgentChatSlice/assets/agConfig.ts create mode 100644 web/oss/src/components/AgentChatSlice/assets/constants.ts create mode 100644 web/oss/src/components/AgentChatSlice/assets/files.ts create mode 100644 web/oss/src/components/AgentChatSlice/assets/loadSession.ts create mode 100644 web/oss/src/components/AgentChatSlice/assets/markdown.tsx create mode 100644 web/oss/src/components/AgentChatSlice/assets/rewind.ts create mode 100644 web/oss/src/components/AgentChatSlice/assets/toAgentaMessage.ts create mode 100644 web/oss/src/components/AgentChatSlice/assets/trace.ts create mode 100644 web/oss/src/components/AgentChatSlice/assets/transport.ts create mode 100644 web/oss/src/components/AgentChatSlice/components/AgentChatConversation.tsx create mode 100644 web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx create mode 100644 web/oss/src/components/AgentChatSlice/components/SessionHistoryMenu.tsx create mode 100644 web/oss/src/components/AgentChatSlice/components/SessionTabLabel.tsx create mode 100644 web/oss/src/components/AgentChatSlice/components/ToolPart.tsx create mode 100644 web/oss/src/components/AgentChatSlice/index.tsx create mode 100644 web/oss/src/components/AgentChatSlice/state/sessions.ts create mode 100644 web/oss/src/components/pages/observability/components/SessionsTable/assets/sessionCellStore.tsx create mode 100644 web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/agent-chat/index.tsx create mode 100644 web/packages/agenta-entities/tests/unit/derive-workflow-type-agent.test.ts create mode 100644 web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentConfigControl.tsx create mode 100644 web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/McpServerItemControl.tsx create mode 100644 web/packages/agenta-playground/src/state/execution/agentRequest.ts create mode 100644 web/packages/agenta-playground/tests/unit/agentMode.test.ts create mode 100644 web/packages/agenta-playground/tests/unit/agentRequest.test.ts diff --git a/examples/python/RAG_QA_chatbot/backend/agent_loop.py b/examples/python/RAG_QA_chatbot/backend/agent_loop.py new file mode 100644 index 0000000000..ce18a036ef --- /dev/null +++ b/examples/python/RAG_QA_chatbot/backend/agent_loop.py @@ -0,0 +1,588 @@ +"""Real agentic loop emitting the v6 UI Message Stream protocol. + +This is the un-mocked counterpart to the canned generators in `contract_stream.py`. +It drives a real LLM (via litellm) through a function-calling loop with two tools: + + * ``search_docs`` — auto-executed; runs the real Qdrant retrieval in ``rag.py``. + * ``send_summary_email`` — human-in-the-loop; the turn PAUSES on a v6 + ``tool-approval-request`` and only executes on resume, + after the user approves. + +The conversation is stateless across turns (cold/replay model): on the resume request the +frontend re-POSTs the full history, we reconstruct the OpenAI ``messages`` from it, resolve +the pending approval, and continue the loop. The real Agenta trace id is read from the +span and emitted as ``data-trace`` so "View trace" resolves in the Agenta UI. + +The emitted wire parts are byte-for-byte the same v6 contract the mock proves; only the +*content* is now real (real tokens, real retrieved sources, real tool side-effects, a real +trace). Tool-calls and approval are real because this is a genuine agent loop — the thing +the RAG bot in ``main.py`` is not. +""" + +import asyncio +import json +import os +import smtplib +import uuid +from email.message import EmailMessage +from pathlib import Path +from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple + +# Delay between streamed tool-output chunks (search hits revealed one at a time). The v6 +# protocol has no tool-output-delta, but `tool-output-available` accepts a `preliminary` +# flag, so we emit the growing output as preliminary updates then a final full one. +OUTPUT_CHUNK_DELAY_S = float(os.getenv("AGENT_OUTPUT_CHUNK_DELAY", "0.12")) + +# Heavy deps (litellm / qdrant via rag / agenta) are imported lazily inside the functions +# that use them, so this module's pure helpers stay importable without credentials. + +# Tools that run immediately vs. tools gated behind human approval. +AUTO_TOOLS = {"search_docs"} +APPROVAL_TOOLS = {"send_summary_email"} + +# Cap the agentic loop so a misbehaving model can't spin forever. +MAX_STEPS = 6 + +AGENT_SYSTEM_PROMPT = ( + "You are Agenta's documentation assistant. " + "Always call the `search_docs` tool to ground your answer in the docs before " + "replying, and cite the document titles you used. " + "If the user asks you to email a summary to someone, call `send_summary_email` — " + "that tool requires explicit human approval before it runs, so call it and wait. " + "Answer concisely in markdown." +) + +TOOL_SPECS: List[Dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "search_docs", + "description": "Search the Agenta documentation for passages relevant to a query.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "The search query."}, + "top_k": { + "type": "integer", + "description": "How many passages to return.", + }, + }, + "required": ["query"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "send_summary_email", + "description": "Send a summary email. Requires human approval before it runs.", + "parameters": { + "type": "object", + "properties": { + "to": {"type": "string", "description": "Recipient email address."}, + "subject": {"type": "string"}, + "body": {"type": "string"}, + }, + "required": ["to", "subject", "body"], + }, + }, + }, +] + + +def _sse(obj: Dict[str, Any]) -> str: + return f"data: {json.dumps(obj)}\n\n" + + +# --------------------------------------------------------------------------- +# Real tool execution +# --------------------------------------------------------------------------- + + +def _run_search_docs(args: Dict[str, Any]) -> Tuple[Dict[str, Any], List[Any]]: + """Execute the real Qdrant-backed retrieval. Returns (tool_output, docs).""" + from .rag import retrieve # lazy: qdrant/litellm only loaded in real mode + + query = (args.get("query") or "").strip() + top_k = args.get("top_k") + docs = retrieve(query, top_k=top_k) + output = { + "hits": [ + { + "title": d.title, + "url": d.url, + "score": round(d.score, 3), + "snippet": (d.content or "")[:240], + } + for d in docs + ] + } + return output, docs + + +def _run_send_email(args: Dict[str, Any]) -> Dict[str, Any]: + """Really send the email when SMTP is configured; otherwise record it locally. + + Either way a real side-effect happens — this is not a fabricated ``{status: sent}``. + Configure SMTP via SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASSWORD / SMTP_FROM to + send for real; without it the message is appended to ``sent_emails.jsonl`` so the + approval gate still has an observable effect with no extra credentials. + """ + to = args.get("to") or "" + subject = args.get("subject") or "" + body = args.get("body") or "" + + host = os.getenv("SMTP_HOST") + if host: + msg = EmailMessage() + msg["From"] = os.getenv( + "SMTP_FROM", os.getenv("SMTP_USER", "agent@example.com") + ) + msg["To"] = to + msg["Subject"] = subject + msg.set_content(body) + with smtplib.SMTP(host, int(os.getenv("SMTP_PORT", "587"))) as server: + server.starttls() + user, password = os.getenv("SMTP_USER"), os.getenv("SMTP_PASSWORD") + if user and password: + server.login(user, password) + server.send_message(msg) + return {"status": "sent", "transport": "smtp", "to": to} + + # No SMTP configured — record locally (a real, inspectable side-effect). + log_path = Path(__file__).resolve().parent.parent / "sent_emails.jsonl" + record = {"to": to, "subject": subject, "body": body} + with log_path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(record) + "\n") + return { + "status": "recorded", + "transport": "local-file", + "path": str(log_path), + "to": to, + } + + +# --------------------------------------------------------------------------- +# Streaming one model step → v6 parts +# --------------------------------------------------------------------------- + + +async def _stream_step( + messages: List[Dict[str, Any]], + model: str, +) -> AsyncGenerator[Any, None]: + """Call the model once (streaming) and yield v6 SSE strings for text/reasoning. + + The final item yielded is a ``("__result__", text, tool_calls)`` tuple carrying the + assembled assistant text and any tool calls, so the caller can drive the loop. + """ + from litellm import acompletion # lazy: only needed in real mode + + response = await acompletion( + model=model, + messages=messages, + tools=TOOL_SPECS, + tool_choice="auto", + stream=True, + ) + + text_id: Optional[str] = None + reasoning_id: Optional[str] = None + reasoning_closed = False + text_buf = "" + tool_acc: Dict[int, Dict[str, str]] = {} + + async for chunk in response: + choice = chunk.choices[0] + delta = choice.delta + + reasoning = getattr(delta, "reasoning_content", None) + if reasoning: + if reasoning_id is None: + reasoning_id = str(uuid.uuid4()) + yield _sse({"type": "reasoning-start", "id": reasoning_id}) + yield _sse( + {"type": "reasoning-delta", "id": reasoning_id, "delta": reasoning} + ) + + content = getattr(delta, "content", None) + if content: + if reasoning_id is not None and not reasoning_closed: + yield _sse({"type": "reasoning-end", "id": reasoning_id}) + reasoning_closed = True + if text_id is None: + text_id = str(uuid.uuid4()) + yield _sse({"type": "text-start", "id": text_id}) + text_buf += content + yield _sse({"type": "text-delta", "id": text_id, "delta": content}) + + for tc in getattr(delta, "tool_calls", None) or []: + acc = tool_acc.setdefault(tc.index, {"id": "", "name": "", "args": "", "started": ""}) + if tc.id: + acc["id"] = tc.id + fn = getattr(tc, "function", None) + if fn and fn.name: + acc["name"] += fn.name + + # Open the v6 tool part as soon as we know id + name, then stream the input + # JSON as `tool-input-delta` chunks so the call's input renders progressively + # (client part state `input-streaming`) instead of appearing all at once. + if not acc["started"] and acc["id"] and acc["name"]: + acc["started"] = "1" + yield _sse( + {"type": "tool-input-start", "toolCallId": acc["id"], "toolName": acc["name"]} + ) + if acc["args"]: # flush args that arrived before the name + yield _sse( + { + "type": "tool-input-delta", + "toolCallId": acc["id"], + "inputTextDelta": acc["args"], + } + ) + + if fn and fn.arguments: + acc["args"] += fn.arguments + if acc["started"]: + yield _sse( + { + "type": "tool-input-delta", + "toolCallId": acc["id"], + "inputTextDelta": fn.arguments, + } + ) + + if reasoning_id is not None and not reasoning_closed: + yield _sse({"type": "reasoning-end", "id": reasoning_id}) + if text_id is not None: + yield _sse({"type": "text-end", "id": text_id}) + + tool_calls = [tool_acc[idx] for idx in sorted(tool_acc)] + yield ("__result__", text_buf, tool_calls) + + +def _parse_args(raw: str) -> Dict[str, Any]: + try: + return json.loads(raw) if raw else {} + except (json.JSONDecodeError, TypeError): + return {} + + +# --------------------------------------------------------------------------- +# History reconstruction (stateless resume) +# --------------------------------------------------------------------------- + + +def _messages_from_uimessage(body: Dict[str, Any]) -> List[Dict[str, Any]]: + """Track A: rebuild OpenAI messages from AI SDK ``UIMessage[]`` (parts).""" + out: List[Dict[str, Any]] = [] + for m in body.get("messages") or []: + role = m.get("role") + parts = m.get("parts") or [] + text = " ".join( + p.get("text", "") for p in parts if p.get("type") == "text" + ).strip() + if role == "user": + out.append({"role": "user", "content": text}) + elif role == "assistant": + tool_parts = [ + p for p in parts if str(p.get("type", "")).startswith("tool-") + ] + if tool_parts: + out.append( + { + "role": "assistant", + "content": text or None, + "tool_calls": [ + { + "id": p.get("toolCallId"), + "type": "function", + "function": { + "name": str(p["type"])[len("tool-") :], + "arguments": json.dumps(p.get("input") or {}), + }, + } + for p in tool_parts + ], + } + ) + # Tool results for calls that already resolved. The pending approval call + # (no output yet) is intentionally left unresolved; the loop adds it. + for p in tool_parts: + state = p.get("state") + if state == "output-available": + out.append( + { + "role": "tool", + "tool_call_id": p.get("toolCallId"), + "content": json.dumps(p.get("output")), + } + ) + elif state == "output-denied": + out.append( + { + "role": "tool", + "tool_call_id": p.get("toolCallId"), + "content": json.dumps({"status": "denied"}), + } + ) + elif text: + out.append({"role": "assistant", "content": text}) + return out + + +def _messages_from_agenta(body: Dict[str, Any]) -> List[Dict[str, Any]]: + """Track B: the FE already sends OpenAI-shaped messages; pass through the fields the + model needs (role/content/tool_calls/tool_call_id/name), dropping UI-only extras.""" + out: List[Dict[str, Any]] = [] + for m in body.get("messages") or []: + msg: Dict[str, Any] = {"role": m.get("role"), "content": m.get("content")} + if m.get("tool_calls"): + msg["tool_calls"] = m["tool_calls"] + if m.get("tool_call_id"): + msg["tool_call_id"] = m["tool_call_id"] + if m.get("name"): + msg["name"] = m["name"] + out.append(msg) + return out + + +# --------------------------------------------------------------------------- +# The turn +# --------------------------------------------------------------------------- + + +async def run_turn( + body: Dict[str, Any], + track: str, + pending: List[Dict[str, Any]], +) -> AsyncGenerator[str, None]: + """Drive one agent turn, emitting v6 SSE strings. `pending` are approval decisions + detected from the request (toolCallId, toolName, input, approved).""" + from .config import settings # lazy: pulls python-dotenv only in real mode + + model = settings.LLM_MODEL + # Echo the resolved session_id on the `start` part per the RFC (§6.2.4). + start: Dict[str, Any] = {"type": "start", "messageId": str(uuid.uuid4())} + if body.get("session_id"): + start["messageMetadata"] = {"sessionId": body["session_id"]} + yield _sse(start) + + history = ( + _messages_from_agenta(body) + if track == "agenta" + else _messages_from_uimessage(body) + ) + messages: List[Dict[str, Any]] = [ + {"role": "system", "content": AGENT_SYSTEM_PROMPT}, + *history, + ] + + # Open a real Agenta span so the run is traced and we can surface its trace id. + trace_id = None + answer_text = "" + span_cm = _open_span("agent_chat") + span = span_cm.__enter__() if span_cm else None + try: + # Record the conversation on the parent span (children auto-capture their own data). + _set_span_data("inputs", {"messages": history}) + + # 1) Resolve any pending approval decisions first (resume path). + for tool in pending: + tool_call_id = tool["toolCallId"] + if tool["approved"]: + output = _run_send_email(tool.get("input") or {}) + yield _sse( + { + "type": "tool-output-available", + "toolCallId": tool_call_id, + "output": output, + } + ) + messages.append( + { + "role": "tool", + "tool_call_id": tool_call_id, + "content": json.dumps(output), + } + ) + else: + yield _sse({"type": "tool-output-denied", "toolCallId": tool_call_id}) + messages.append( + { + "role": "tool", + "tool_call_id": tool_call_id, + "content": json.dumps( + {"status": "denied", "note": "User declined."} + ), + } + ) + + # 2) Agentic loop: model → tools → model … until a final text or an approval pause. + for _ in range(MAX_STEPS): + text_buf = "" + tool_calls: List[Dict[str, str]] = [] + async for item in _stream_step(messages, model): + if isinstance(item, tuple) and item and item[0] == "__result__": + _, text_buf, tool_calls = item + else: + yield item + if text_buf: + answer_text += text_buf + + assistant_msg: Dict[str, Any] = { + "role": "assistant", + "content": text_buf or None, + } + if tool_calls: + assistant_msg["tool_calls"] = [ + { + "id": tc["id"], + "type": "function", + "function": { + "name": tc["name"], + "arguments": tc["args"] or "{}", + }, + } + for tc in tool_calls + ] + messages.append(assistant_msg) + + if not tool_calls: + break # model produced a final answer + + approval_pending = False + for tc in tool_calls: + name, call_id = tc["name"], tc["id"] + args = _parse_args(tc["args"]) + # `tool-input-start` + `tool-input-delta`s were already streamed in + # `_stream_step`; here we just finalize the input. + yield _sse( + { + "type": "tool-input-available", + "toolCallId": call_id, + "toolName": name, + "input": args, + } + ) + + if name in AUTO_TOOLS: + output, docs = _run_search_docs(args) + for d in docs[:5]: + yield _sse( + { + "type": "source-url", + "sourceId": d.url, + "url": d.url, + "title": d.title, + } + ) + # Reveal the hits progressively as `preliminary` outputs, then a + # final full output. (The retrieval itself is one call — this just + # streams the rendering of the already-computed result.) + hits = output.get("hits") if isinstance(output, dict) else None + if isinstance(hits, list) and len(hits) > 1: + for k in range(1, len(hits)): + yield _sse( + { + "type": "tool-output-available", + "toolCallId": call_id, + "output": {"hits": hits[:k]}, + "preliminary": True, + } + ) + await asyncio.sleep(OUTPUT_CHUNK_DELAY_S) + yield _sse( + { + "type": "tool-output-available", + "toolCallId": call_id, + "output": output, + } + ) + messages.append( + { + "role": "tool", + "tool_call_id": call_id, + "content": json.dumps(output), + } + ) + elif name in APPROVAL_TOOLS: + yield _sse( + { + "type": "tool-approval-request", + "approvalId": f"approval_{uuid.uuid4().hex[:12]}", + "toolCallId": call_id, + } + ) + approval_pending = True + + if approval_pending: + break # pause the turn for human approval + + _set_span_data("outputs", {"response": answer_text}) + trace_id = _trace_id_of(span) + finally: + if span_cm: + span_cm.__exit__(None, None, None) + + if trace_id: + # data-trace part (legacy/fallback channel) … + yield _sse( + { + "type": "data-trace", + "data": { + "traceId": trace_id, + "url": f"{settings.AGENTA_HOST}/observability/traces/{trace_id}", + }, + } + ) + # … and the RFC-aligned channel: traceId on the finish messageMetadata. + yield _sse({"type": "finish", "messageMetadata": {"traceId": trace_id}}) + else: + yield _sse({"type": "finish"}) + yield "data: [DONE]\n\n" + + +# --------------------------------------------------------------------------- +# Agenta tracing helpers (no-op if the SDK isn't initialized) +# --------------------------------------------------------------------------- + + +def _open_span(name: str): + try: + import agenta as ag + + return ag.tracer.start_as_current_span(name) + except Exception: + return None + + +def _set_span_data(key: str, value: Any) -> None: + """Set `inputs`/`outputs` on the active Agenta span (no-op if tracing isn't init'd). + + The parent `agent_chat` span is a manual span, so it doesn't auto-capture data the way + `@ag.instrument()` children do — we populate it explicitly so the trace shows the + conversation in and the final answer out. + """ + try: + import agenta as ag + + span = ag.tracing.get_current_span() + if span is not None: + span.set_attributes({key: value}, namespace="data") + except Exception: + pass + + +def _trace_id_of(span) -> Optional[str]: + if span is None: + return None + try: + from opentelemetry.trace import format_trace_id + + ctx = span.get_span_context() + if ctx and ctx.is_valid: + return format_trace_id(ctx.trace_id) + except Exception: + return None + return None diff --git a/examples/python/RAG_QA_chatbot/backend/contract_stream.py b/examples/python/RAG_QA_chatbot/backend/contract_stream.py new file mode 100644 index 0000000000..f0cb271375 --- /dev/null +++ b/examples/python/RAG_QA_chatbot/backend/contract_stream.py @@ -0,0 +1,162 @@ +"""Agent chat slice endpoints — the real LLM agent loop over the v6 UI Message Stream. + +Two endpoints serve the streaming agent chat the frontend `useChat` hook consumes, one per +request-contract track (the team is still comparing them — see +`docs/design/agent-workflows/frontend-agent-chat-ui.md`): + + * **Track A** — `POST /api/agent/chat`. `messages` is the AI SDK `UIMessage[]` (parts); + the approval decision rides inside the assistant message's tool part. + * **Track B** — `POST /api/agent/chat-agenta`. `messages` is the Agenta `{role, content}` + shape; the approval decision rides in a top-level `tool_approvals` side field. + +Request envelope (FE as of 2026-06-19): `session_id` + `references` (+ Track B +`tool_approvals`) at the top level, with `data: {messages, parameters}` nested. +`_normalize_envelope` lifts `data.*` back to flat keys so the parsing below stays simple, +and it still accepts the older flat `{messages, ...}` shape. + +The response stream is identical across tracks. Both delegate to the real agent loop in +`agent_loop.py` (real LLM function-calling, real `search_docs` retrieval, an approval-gated +`send_summary_email`, a real Agenta trace). **Credentials are required** — set up +`.env` (OPENAI_API_KEY + QDRANT_URL/KEY + AGENTA_*) and ingest the docs; there is no +credential-free mock. The framing is SSE (`data: \\n\\n`, terminated by `[DONE]`, +header `x-vercel-ai-ui-message-stream: v1`); `session_id` is echoed on the `start` part's +`messageMetadata.sessionId`. +""" + +from typing import Any, Dict, List + +from fastapi import APIRouter, Request +from fastapi.responses import StreamingResponse + +from . import agent_loop + +router = APIRouter() + + +# ---- Track A: approvals read from UIMessage tool parts --------------------------------- + + +def _pending_approvals_uimessage( + messages: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """Tool parts the user has just approved/denied but that have no output yet. + + Track A: the FE encodes the decision on the assistant message's tool part as + `state == "approval-responded"` with `approval: {id, approved}`. + """ + pending: List[Dict[str, Any]] = [] + for msg in messages: + if msg.get("role") != "assistant": + continue + for part in msg.get("parts") or []: + ptype = part.get("type", "") + if not ptype.startswith("tool-"): + continue + if part.get("state") != "approval-responded": + continue + approval = part.get("approval") or {} + pending.append( + { + "toolCallId": part.get("toolCallId"), + "toolName": ptype[len("tool-") :], + "input": part.get("input"), + "approved": bool(approval.get("approved")), + } + ) + return pending + + +# ---- Track B: approvals read from the `tool_approvals` side channel -------------------- + + +def _pending_approvals_agenta(body: Dict[str, Any]) -> List[Dict[str, Any]]: + """Track B: the Agenta `{role, content}` message contract has no slot for an approval + decision, so the FE adapter surfaces it in a top-level `tool_approvals` field: + + "tool_approvals": [ { "tool_call_id": "call_x", "approved": true } ] + + An entry is "pending" only while the matching tool call has no `tool` result message + yet — the same window Track A detects via `state == "approval-responded"`. + """ + approvals = body.get("tool_approvals") or [] + if not approvals: + return [] + + # tool_call_ids that already have a result (so they are no longer pending) + resolved: set = set() + for msg in body.get("messages") or []: + if msg.get("role") == "tool" and msg.get("tool_call_id"): + resolved.add(msg["tool_call_id"]) + + pending: List[Dict[str, Any]] = [] + for entry in approvals: + tool_call_id = entry.get("tool_call_id") + if not tool_call_id or tool_call_id in resolved: + continue + pending.append( + { + "toolCallId": tool_call_id, + "toolName": entry.get("tool_name", "tool"), + "input": entry.get("input"), + "approved": bool(entry.get("approved")), + } + ) + return pending + + +def _normalize_envelope(body: Dict[str, Any]) -> Dict[str, Any]: + """Accept the agent-protocol envelope `{session_id, references, data: {messages, + parameters}}` (what the FE sends) while staying backward-compatible with the older flat + `{messages, ag_config, ...}` shape. + + Lifts `data.messages` / `data.parameters` to the top level so the per-track parsing + below and `agent_loop.run_turn` can keep reading flat keys unchanged. `session_id` and + `tool_approvals` already travel at the top level, so they need no remapping. + """ + data = body.get("data") + if not isinstance(data, dict): + return body + merged = dict(body) + if "messages" not in merged and "messages" in data: + merged["messages"] = data.get("messages") + if "parameters" in data: + merged.setdefault("parameters", data.get("parameters")) + merged.setdefault("ag_config", data.get("parameters")) # legacy alias + return merged + + +def _build_response(body: Dict[str, Any], track: str) -> StreamingResponse: + """Parse the request per track, then stream the real agent loop as a v6 SSE response.""" + body = _normalize_envelope(body) + messages: List[Dict[str, Any]] = body.get("messages") or [] + pending = ( + _pending_approvals_agenta(body) + if track == "agenta" + else _pending_approvals_uimessage(messages) + ) + return StreamingResponse( + agent_loop.run_turn(body, track, pending), + media_type="text/event-stream", + headers={ + "x-vercel-ai-ui-message-stream": "v1", + "cache-control": "no-cache", + }, + ) + + +@router.post("/api/agent/chat") +async def agent_chat(request: Request) -> StreamingResponse: + """Track A — request `messages` is the AI SDK `UIMessage[]` shape (`{role, parts}`).""" + return _build_response(await request.json(), track="uimessage") + + +@router.post("/api/agent/chat-agenta") +async def agent_chat_agenta(request: Request) -> StreamingResponse: + """Track B — request `messages` is the Agenta `{role, content}` shape; the approval + decision rides in the `tool_approvals` side field.""" + return _build_response(await request.json(), track="agenta") + + +@router.get("/api/agent/health") +async def agent_health() -> Dict[str, str]: + return {"status": "healthy", "endpoint": "agent chat slice (real agent loop)"} diff --git a/examples/python/RAG_QA_chatbot/backend/main.py b/examples/python/RAG_QA_chatbot/backend/main.py index 4c4d275aae..556c58091a 100644 --- a/examples/python/RAG_QA_chatbot/backend/main.py +++ b/examples/python/RAG_QA_chatbot/backend/main.py @@ -13,6 +13,7 @@ from fastapi.responses import StreamingResponse from .config import settings +from .contract_stream import router as contract_router from .rag import format_context, generate, retrieve # Initialize Agenta for observability @@ -64,6 +65,11 @@ class ChatRequest(BaseModel): model_config = {"extra": "ignore"} # tolerate id, trigger, etc. from AI SDK v4+ +# Agent chat slice endpoints (POST /api/agent/chat[-agenta]) — the real agent loop over +# the v6 UI Message Stream. Requires credentials. See backend/contract_stream.py. +app.include_router(contract_router) + + @app.get("/health") async def health(): """Health check endpoint.""" diff --git a/examples/python/RAG_QA_chatbot/backend/rag.py b/examples/python/RAG_QA_chatbot/backend/rag.py index d95c9f69b0..77c7cb37c0 100644 --- a/examples/python/RAG_QA_chatbot/backend/rag.py +++ b/examples/python/RAG_QA_chatbot/backend/rag.py @@ -1,7 +1,9 @@ """RAG logic: retrieve and generate.""" +import re from dataclasses import dataclass from typing import AsyncGenerator, List, Optional, Tuple +from urllib.parse import urlsplit, urlunsplit import agenta as ag from agenta.sdk.managers.shared import SharedManager @@ -11,6 +13,27 @@ from .config import settings +_DOCUSAURUS_ORDER_PREFIX = re.compile(r"^\d+-") + + +def normalize_doc_url(url: str) -> str: + """Strip Docusaurus numeric ordering prefixes (`NN-`) from each path segment. + + The `.mdx` filenames carry sidebar-ordering prefixes (`01-architecture.mdx`) that the + public docs site drops from the URL (`/architecture`). Older ingests stored the URL with + the prefix, which 404s — this repairs them at read time so source links resolve. + """ + if not url: + return url + try: + parts = urlsplit(url) + except ValueError: + return url + new_path = "/".join( + _DOCUSAURUS_ORDER_PREFIX.sub("", seg) for seg in parts.path.split("/") + ) + return urlunsplit(parts._replace(path=new_path)) + @dataclass class RetrievedDoc: @@ -85,7 +108,7 @@ def retrieve( RetrievedDoc( content=point.payload["content"], title=point.payload["title"], - url=point.payload["url"], + url=normalize_doc_url(point.payload["url"]), score=point.score, ) ) diff --git a/examples/python/RAG_QA_chatbot/env.example b/examples/python/RAG_QA_chatbot/env.example index 5c37eb9a9b..72066e8143 100644 --- a/examples/python/RAG_QA_chatbot/env.example +++ b/examples/python/RAG_QA_chatbot/env.example @@ -26,3 +26,14 @@ TOP_K=10 # =========================================== AGENTA_API_KEY=your-agenta-api-key AGENTA_HOST=https://cloud.agenta.ai + +# =========================================== +# Agent chat slice (POST /api/agent/chat[-agenta]) +# =========================================== +# Optional: make the approval-gated `send_summary_email` tool send for real. Without +# these it records the message to sent_emails.jsonl (still a real, inspectable effect). +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_USER=apikey +# SMTP_PASSWORD=your-smtp-password +# SMTP_FROM=agent@example.com diff --git a/examples/python/RAG_QA_chatbot/ingest/fix_urls.py b/examples/python/RAG_QA_chatbot/ingest/fix_urls.py new file mode 100644 index 0000000000..9e7ddb0158 --- /dev/null +++ b/examples/python/RAG_QA_chatbot/ingest/fix_urls.py @@ -0,0 +1,64 @@ +"""Backfill corrected public docs URLs into the vector-store payloads, in place. + +The public URL is derived from each doc's file path + frontmatter `slug` (see `loaders.py`: +Docusaurus strips numeric ordering prefixes, and an absolute frontmatter `slug` overrides +the path). Older ingests stored stale URLs (kept the `NN-` prefix, ignored frontmatter +slugs) that 404. This rewrites ONLY the `url` payload field — no re-embedding, no model +cost — by re-deriving URLs with the current loader and matching points by `file_path`. + + python -m ingest.fix_urls --source ../../../docs/docs --base-url https://docs.agenta.ai +""" + +import argparse +import os +from collections import defaultdict + +from dotenv import load_dotenv +from qdrant_client import QdrantClient + +from .loaders import load_mdx + + +def main(): + parser = argparse.ArgumentParser(description="Backfill corrected doc URLs in Qdrant") + parser.add_argument("--source", required=True, help="Path to docs directory") + parser.add_argument("--base-url", required=True, help="Base URL for doc links") + parser.add_argument("--collection", default=None, help="Collection (default: from env)") + args = parser.parse_args() + + load_dotenv() + collection = args.collection or os.getenv("COLLECTION_NAME", "docs_collection") + + url_by_path = {d.file_path: d.url for d in load_mdx(args.source, args.base_url)} + print(f"Re-derived {len(url_by_path)} URLs from {args.source}") + + client = QdrantClient(url=os.getenv("QDRANT_URL"), api_key=os.getenv("QDRANT_API_KEY")) + + pending: dict[str, list] = defaultdict(list) # correct_url -> [point ids needing it] + scanned = 0 + offset = None + while True: + points, offset = client.scroll( + collection, limit=256, with_payload=True, offset=offset + ) + for p in points: + scanned += 1 + correct = url_by_path.get(p.payload.get("file_path")) + if correct and correct != p.payload.get("url"): + pending[correct].append(p.id) + if offset is None: + break + + updated = 0 + for url, ids in pending.items(): + client.set_payload(collection, payload={"url": url}, points=ids) + updated += len(ids) + + print( + f"Scanned {scanned} points; updated {updated} URLs across " + f"{len(pending)} docs in '{collection}'." + ) + + +if __name__ == "__main__": + main() diff --git a/examples/python/RAG_QA_chatbot/ingest/loaders.py b/examples/python/RAG_QA_chatbot/ingest/loaders.py index 594c602740..de76643533 100644 --- a/examples/python/RAG_QA_chatbot/ingest/loaders.py +++ b/examples/python/RAG_QA_chatbot/ingest/loaders.py @@ -2,6 +2,7 @@ import glob import os +import re from dataclasses import dataclass from pathlib import Path from typing import List @@ -41,9 +42,18 @@ def load_mdx(docs_path: str, base_url: str) -> List[Document]: # Get title from frontmatter or filename title = post.get("title", Path(file_path).stem) - # Convert file path to URL - relative_path = os.path.relpath(file_path, docs_path) - url_path = os.path.splitext(relative_path)[0] + # Convert file path to the public docs URL. Docusaurus strips numeric + # ordering prefixes (`01-architecture.mdx` → `/architecture`), so strip + # `NN-` from each path segment. An absolute frontmatter `slug` wins. + slug = post.get("slug") + if isinstance(slug, str) and slug.startswith("/"): + url_path = slug.strip("/") + else: + relative_path = os.path.relpath(file_path, docs_path) + no_ext = os.path.splitext(relative_path)[0] + url_path = "/".join( + re.sub(r"^\d+-", "", seg) for seg in no_ext.split(os.sep) + ) url = f"{base_url.rstrip('/')}/{url_path}" documents.append( diff --git a/examples/python/RAG_QA_chatbot/ingest/store.py b/examples/python/RAG_QA_chatbot/ingest/store.py index 578b5f83d1..a1911ac882 100644 --- a/examples/python/RAG_QA_chatbot/ingest/store.py +++ b/examples/python/RAG_QA_chatbot/ingest/store.py @@ -71,29 +71,40 @@ def setup_collection( ) -def get_embeddings(text: str) -> Dict[str, List[float]]: +def _active_embedding_models() -> List[str]: + """Which named vectors to populate, honoring EMBEDDING_MODEL. + + `openai` (default) / `cohere` pick one; `both` populates both named vectors. The + retrieval path (`rag.py`) queries the single `using=EMBEDDING_MODEL` vector, so there + is no need to embed the other provider — and embedding both was force-calling Cohere + even when EMBEDDING_MODEL=openai, exhausting its trial rate limit. """ - Get embeddings using both OpenAI and Cohere models. + model = os.getenv("EMBEDDING_MODEL", "openai").strip().lower() + if model == "cohere": + return ["cohere"] + if model == "both": + return ["openai", "cohere"] + return ["openai"] - Args: - text: Text to embed - Returns: - Dict with 'openai' and 'cohere' embeddings - """ - # OpenAI embedding - openai_response = embedding(model="text-embedding-ada-002", input=[text]) - openai_embedding = openai_response["data"][0]["embedding"] - - # Cohere embedding - cohere_response = embedding( - model="cohere/embed-english-v3.0", - input=[text], - input_type="search_document", - ) - cohere_embedding = cohere_response["data"][0]["embedding"] +def embed_texts(texts: List[str]) -> Dict[str, List[List[float]]]: + """Batch-embed a list of texts for each active provider (one API call per provider). - return {"openai": openai_embedding, "cohere": cohere_embedding} + Returns provider → list of vectors, aligned with `texts`. + """ + out: Dict[str, List[List[float]]] = {} + models_ = _active_embedding_models() + if "openai" in models_: + resp = embedding(model="text-embedding-ada-002", input=texts) + out["openai"] = [d["embedding"] for d in resp["data"]] + if "cohere" in models_: + resp = embedding( + model="cohere/embed-english-v3.0", + input=texts, + input_type="search_document", + ) + out["cohere"] = [d["embedding"] for d in resp["data"]] + return out def generate_chunk_id(chunk: Chunk) -> str: @@ -113,30 +124,27 @@ def upsert_chunks(client: QdrantClient, collection_name: str, chunks: List[Chunk collection_name: Name of the collection chunks: List of chunks to upsert """ - for chunk in chunks: - # Get embeddings - embeddings = get_embeddings(chunk.content) - - # Create payload - payload = { - "content": chunk.content, - "title": chunk.title, - "url": chunk.url, - "file_path": chunk.file_path, - "chunk_index": chunk.chunk_index, - } - - # Generate unique ID - point_id = generate_chunk_id(chunk) - - # Upsert to Qdrant - client.upsert( - collection_name=collection_name, - points=[ - models.PointStruct( - id=point_id, - payload=payload, - vector=embeddings, - ) - ], + if not chunks: + return + + # One embedding call per provider for the whole batch, then one upsert. + vectors_by_model = embed_texts([chunk.content for chunk in chunks]) + + points = [] + for i, chunk in enumerate(chunks): + vector = {model: vecs[i] for model, vecs in vectors_by_model.items()} + points.append( + models.PointStruct( + id=generate_chunk_id(chunk), + payload={ + "content": chunk.content, + "title": chunk.title, + "url": chunk.url, + "file_path": chunk.file_path, + "chunk_index": chunk.chunk_index, + }, + vector=vector, + ) ) + + client.upsert(collection_name=collection_name, points=points) diff --git a/examples/python/RAG_QA_chatbot/run-agent-chat-slice.sh b/examples/python/RAG_QA_chatbot/run-agent-chat-slice.sh new file mode 100644 index 0000000000..1beccac979 --- /dev/null +++ b/examples/python/RAG_QA_chatbot/run-agent-chat-slice.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# +# Orchestrate the agent-chat slice: the REAL agent backend + the web dev server. +# +# ./examples/python/RAG_QA_chatbot/run-agent-chat-slice.sh +# +# Brings up: +# 1. The real agent backend (FastAPI) on :8000 — POST /api/agent/chat[-agenta], v6 UI +# Message Stream, real LLM + Qdrant retrieval + Agenta trace. +# 2. The web app (Next dev) with the slice flag on. +# +# Then visit: http://localhost:3000/w//p//apps//agent-chat +# Flip the A · UIMessage parts / B · Agenta {role,content} toggle on the page. +# +# REQUIRES credentials: a populated .env (OPENAI_API_KEY + QDRANT_URL/KEY + AGENTA_*) and +# the docs ingested into Qdrant. Ctrl-C tears both down. + +set -euo pipefail + +# --- config (override via env) --------------------------------------------- +BACKEND_PORT="${BACKEND_PORT:-8000}" +AGENT_CHAT_TRACK="${AGENT_CHAT_TRACK:-}" # "agenta" => default the page to Track B; empty => Track A +APP="${APP:-ee}" # which web app shell to serve: "ee" (default) or "oss" + +case "$APP" in + ee) APP_FILTER="@agenta/ee" ;; + oss) APP_FILTER="@agenta/oss" ;; + *) echo "!! APP must be 'ee' or 'oss', got '$APP'" >&2; exit 1 ;; +esac + +# --- paths ----------------------------------------------------------------- +REPO_ROOT="$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)" +EXAMPLE_DIR="$REPO_ROOT/examples/python/RAG_QA_chatbot" +WEB_DIR="$REPO_ROOT/web" +VENV="$EXAMPLE_DIR/.venv" + +cd "$REPO_ROOT" + +# Credentials are required — there is no credential-free mock. +if [ ! -f "$EXAMPLE_DIR/.env" ]; then + echo "!! Missing $EXAMPLE_DIR/.env" >&2 + echo " Copy env.example → .env and set OPENAI_API_KEY + QDRANT_URL/KEY + AGENTA_*," >&2 + echo " then ingest the docs (see below). The agent backend needs real credentials." >&2 + exit 1 +fi + +# --- teardown -------------------------------------------------------------- +BACKEND_PID="" +cleanup() { + echo "" + echo "==> Shutting down…" + [ -n "$BACKEND_PID" ] && kill "$BACKEND_PID" 2>/dev/null || true + wait 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +# --- 1. backend ------------------------------------------------------------ +if [ ! -d "$VENV" ]; then + echo "==> Installing example deps (first run only)…" + python3 -m venv "$VENV" + "$VENV/bin/pip" install --quiet --upgrade pip + "$VENV/bin/pip" install --quiet -e "$EXAMPLE_DIR" +fi + +echo "==> Starting agent backend (backend.main:app) on :$BACKEND_PORT …" +echo " (real LLM + Qdrant retrieval + Agenta trace; reads $EXAMPLE_DIR/.env)" +echo " Docs must be ingested into Qdrant first, e.g.:" +echo " $VENV/bin/python -m ingest.run --source ../../../docs/docs \\" +echo " --base-url https://docs.agenta.ai --recreate" +APP_MODULE="backend.main:app" +# --reload so backend edits (agent_loop.py, contract_stream.py, …) hot-reload without a +# manual restart while iterating. +( cd "$EXAMPLE_DIR" && exec "$VENV/bin/uvicorn" "$APP_MODULE" --port "$BACKEND_PORT" --reload ) & +BACKEND_PID=$! + +# wait for /health +echo -n "==> Waiting for backend" +for _ in $(seq 1 30); do + if curl -fsS "http://localhost:$BACKEND_PORT/health" >/dev/null 2>&1; then + echo " — up." + break + fi + if ! kill -0 "$BACKEND_PID" 2>/dev/null; then + echo "" + echo "!! Backend exited before becoming healthy. See output above." >&2 + exit 1 + fi + echo -n "." + sleep 1 +done + +# --- 2. web dev server (foreground) ---------------------------------------- +echo "==> Starting web dev server: $APP_FILTER (slice flag on)…" +echo "" +echo " App: $APP_FILTER (override with APP=oss)" +echo " Visit: http://localhost:3000/w//p//apps//agent-chat" +echo " Mock: http://localhost:$BACKEND_PORT/api/agent/chat" +[ -n "$AGENT_CHAT_TRACK" ] && echo " Track: defaulting to '$AGENT_CHAT_TRACK' (page toggle still works)" +echo "" +echo " NOTE: reaching the /w/../p/../apps//agent-chat route needs your authenticated dev" +echo " stack (backend + DB + auth) already running — this script only starts" +echo " the agent backend and the web app." +echo "" + +cd "$WEB_DIR" +NEXT_PUBLIC_AGENT_CHAT_SLICE=true \ + ${AGENT_CHAT_TRACK:+NEXT_PUBLIC_AGENT_CHAT_TRACK="$AGENT_CHAT_TRACK"} \ + pnpm --filter "$APP_FILTER" dev diff --git a/web/ee/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/agent-chat/index.tsx b/web/ee/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/agent-chat/index.tsx new file mode 100644 index 0000000000..2ab7470595 --- /dev/null +++ b/web/ee/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/agent-chat/index.tsx @@ -0,0 +1,3 @@ +import AgentChatPage from "@agenta/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/agent-chat" + +export default AgentChatPage diff --git a/web/oss/package.json b/web/oss/package.json index ad5f9e31ed..d0be5a0fde 100644 --- a/web/oss/package.json +++ b/web/oss/package.json @@ -30,10 +30,12 @@ "@agenta/ui": "workspace:../packages/agenta-ui", "@agenta/web-tests": "workspace:../tests", "@agentaai/nextstepjs": "^2.1.3-agenta.1", + "@ai-sdk/react": "3.0.0-beta.153", "@ant-design/colors": "^7.2.1", "@ant-design/cssinjs": "^2.1.0", "@ant-design/icons": "^6.1.0", "@ant-design/x": "^2.5.0", + "@ant-design/x-markdown": "^2.8.0", "@cloudflare/stream-react": "^1.9.3", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", @@ -74,6 +76,7 @@ "@types/react-resizable": "^3.0.7", "@types/react-syntax-highlighter": "^15.5.7", "@types/react-window": "^1.8.8", + "ai": "6.0.0-beta.150", "ajv": "^8.18.0", "antd": "^6.1.3", "autoprefixer": "10.4.20", @@ -109,6 +112,7 @@ "react-icons": "^5.4.0", "react-jss": "^10.10.0", "react-resizable": "^3.0.5", + "react-syntax-highlighter": "^16.1.1", "react-window": "^1.8.11", "recharts": "^3.1.0", "semver": "^7.7.4", diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx new file mode 100644 index 0000000000..049873839d --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx @@ -0,0 +1,431 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from "react" + +import {buildAgentRequest} from "@agenta/playground" +import {useChat} from "@ai-sdk/react" +import {Attachments, Bubble, Sender} from "@ant-design/x" +import {ArrowDown, Paperclip} from "@phosphor-icons/react" +import { + DefaultChatTransport, + lastAssistantMessageIsCompleteWithApprovalResponses, + type UIMessage, +} from "ai" +import {Alert, Button, Modal, Tabs, Tag, Tooltip} from "antd" +import type {UploadFile} from "antd" +import {useAtomValue, useSetAtom, useStore} from "jotai" + +import {filesToParts} from "./assets/files" +import {messageText, sideEffectingToolsInRange} from "./assets/rewind" +import AgentMessage from "./components/AgentMessage" +import SessionHistoryMenu from "./components/SessionHistoryMenu" +import SessionTabLabel from "./components/SessionTabLabel" +import { + type AgentChatSession, + activeSessionIdAtom, + addSessionAtom, + closeSessionAtom, + persistSessionMessagesAtom, + renameSessionAtom, + sessionFirstUserTextAtomFamily, + sessionMessagesAtom, + sessionsListAtom, + setActiveSessionAtom, +} from "./state/sessions" + +/** + * One agent conversation for a single session tab. A `useChat` whose transport is fed by the + * PLAYGROUND request builder (`buildAgentRequest`) — the entity supplies the config/auth/ + * references, the session id is the tab's id and travels to the backend as `session_id`. + * Messages persist to localStorage (seeded on mount, written when the stream settles) so the + * tab survives a reload / revision swap. + * + * Design decisions baked in (docs/design/agent-workflows/playground-agent-generation.md): + * - D9 teardown: abort the in-flight stream on unmount (tab close / revision swap). + * - DT3 cancelled state: a stopped stream tags its partial bubble "Stopped" + offers Resend. + * - DT4 autoscroll: stick to bottom while streaming; pause when scrolled up; "jump to latest". + * - DT5 a11y: the message log is an aria-live region; controls are keyboard-operable. + */ +const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: string}) => { + const store = useStore() + const persistMessages = useSetAtom(persistSessionMessagesAtom) + + const [input, setInput] = useState("") + const [files, setFiles] = useState([]) + const [attachmentsOpen, setAttachmentsOpen] = useState(false) + // Ids of assistant turns whose stream was stopped (user-cancel or teardown). + const [stoppedIds, setStoppedIds] = useState>(() => new Set()) + // Seed once from the persisted store (read imperatively so our own writes don't feed back). + const [initialMessages] = useState(() => store.get(sessionMessagesAtom)[sessionId] ?? []) + + const senderRef = useRef>(null) + const dropContainerRef = useRef(null) + const scrollRef = useRef(null) + const stickRef = useRef(true) + const [showJump, setShowJump] = useState(false) + + // Transport feeds the v6 stream request from the playground pipeline. `api` here is a + // placeholder that `prepareSendMessagesRequest` overrides per request. + const transport = useMemo( + () => + new DefaultChatTransport({ + api: "", + prepareSendMessagesRequest: async ({messages, id}) => { + const req = await buildAgentRequest(entityId, messages, { + sessionId: id ?? sessionId, + }) + if (!req) { + throw new Error( + "This agent workflow has no invocation URL — it can’t be run yet.", + ) + } + return {api: req.invocationUrl, headers: req.headers, body: req.requestBody} + }, + }), + [entityId, sessionId], + ) + + const { + messages, + sendMessage, + status, + stop, + regenerate, + setMessages, + addToolApprovalResponse, + error, + } = useChat({ + id: sessionId, + messages: initialMessages, + transport, + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses, + onError: (err) => { + console.error("[AgentChatPanel] useChat error:", err) + }, + }) + + const busy = status === "submitted" || status === "streaming" + + // Persist the conversation whenever its stream settles (skip mid-stream). + useEffect(() => { + if (status === "streaming") return + persistMessages({id: sessionId, messages}) + }, [messages, status, sessionId, persistMessages]) + + // ── DT3 cancelled state: wrap stop() to mark the in-flight assistant turn ── + const markStopped = useCallback(() => { + const last = messages[messages.length - 1] + if (last && last.role === "assistant") { + setStoppedIds((prev) => new Set(prev).add(last.id)) + } + }, [messages]) + + const handleStop = useCallback(() => { + markStopped() + stop() + }, [markStopped, stop]) + + // ── D9 teardown: abort the in-flight stream on unmount (tab close / revision swap) ── + // Keyed on sessionId: closing a tab or swapping the revision unmounts this conversation + // and should tear down its stream. + useEffect(() => { + return () => { + stop() + } + }, [sessionId, stop]) + + // ── DT4 autoscroll: stick to bottom while streaming unless scrolled up ── + const scrollToBottom = useCallback(() => { + const el = scrollRef.current + if (el) el.scrollTop = el.scrollHeight + }, []) + + useEffect(() => { + if (stickRef.current) scrollToBottom() + }, [messages, status, scrollToBottom]) + + const onScroll = useCallback(() => { + const el = scrollRef.current + if (!el) return + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24 + stickRef.current = atBottom + setShowJump(!atBottom) + }, []) + + const jumpToLatest = useCallback(() => { + stickRef.current = true + setShowJump(false) + scrollToBottom() + }, [scrollToBottom]) + + const handleSubmit = async (text: string) => { + const trimmed = text.trim() + const fileObjs = files + .map((f) => f.originFileObj as File | undefined) + .filter((f): f is File => Boolean(f)) + if ((!trimmed && fileObjs.length === 0) || busy) return + const fileParts = fileObjs.length ? await filesToParts(fileObjs) : undefined + stickRef.current = true + setShowJump(false) + sendMessage( + fileParts + ? trimmed + ? {text: trimmed, files: fileParts} + : {files: fileParts} + : {text: trimmed}, + ) + setInput("") + setFiles([]) + setAttachmentsOpen(false) + } + + const handleRewind = (message: UIMessage) => { + if (busy) return + const idx = messages.findIndex((m) => m.id === message.id) + if (idx < 0) return + const isUser = message.role === "user" + const sideEffects = sideEffectingToolsInRange(messages.slice(idx)) + + const run = () => { + if (isUser) { + setMessages(messages.slice(0, idx)) + setInput(messageText(message)) + requestAnimationFrame(() => senderRef.current?.focus()) + } else { + regenerate({messageId: message.id}) + } + } + + if (sideEffects.length > 0) { + Modal.confirm({ + title: "Rewind past a tool that already ran?", + content: `${sideEffects.join(", ")} already executed. Rewinding re-runs the conversation from here but will NOT undo it.`, + okText: "Rewind anyway", + okButtonProps: {danger: true}, + cancelText: "Cancel", + onOk: run, + }) + } else { + run() + } + } + + const lastId = messages[messages.length - 1]?.id + + return ( +
+ {error && ( + + )} + +
+
{ + scrollRef.current = el + dropContainerRef.current = el + }} + onScroll={onScroll} + role="log" + aria-live="polite" + aria-label="Agent conversation" + className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overflow-x-hidden rounded-md border border-solid border-colorBorderSecondary p-3" + > + {messages.length === 0 && ( +
+ Ask a question to start the agent conversation. +
+ )} + {messages.map((message, index) => ( +
+ handleRewind(message)} + onApprovalResponse={addToolApprovalResponse} + /> + {stoppedIds.has(message.id) && ( +
+ Stopped + {message.id === lastId && ( + + )} +
+ )} +
+ ))} + {status === "submitted" && + messages[messages.length - 1]?.role !== "assistant" && ( + + )} +
+ + {showJump && ( + + )} +
+ + { + setFiles((prev) => [ + ...prev, + ...Array.from(pasted).map((file) => ({ + uid: `${file.name}-${file.lastModified}-${file.size}`, + name: file.name, + status: "done" as const, + originFileObj: file as UploadFile["originFileObj"], + })), + ]) + setAttachmentsOpen(true) + }} + prefix={ + +
+ ) +} + +/** + * AgentChatPanel — the agent-generation surface hosted INSIDE the playground (the third + * generation arm beside chat and completion). + * + * Single view keeps the slice's editable-card session tab bar (design decision D2): parallel + * conversations, add with `+`, close with `×`, double-click to rename. Sessions are app-scoped + * (shared with the rest of the playground) and persist to localStorage, so tabs survive a + * reload; antd keeps visited panes mounted, so switching tabs preserves a session's live + * stream / approval state. Each tab is its own `useChat` driven by `buildAgentRequest` against + * the current `entityId` (so the run always uses the live draft config). + */ +/** + * Tab label, scoped to its own session: subscribes only to that session's first-user-text + * (a stable string), so a streaming conversation doesn't re-render the whole tab bar / every + * mounted pane on each token. + */ +const TabLabel = ({ + session, + index, + onRename, +}: { + session: AgentChatSession + index: number + onRename: (title: string) => void +}) => { + const text = useAtomValue(sessionFirstUserTextAtomFamily(session.id)) + const truncated = text.length > 24 ? `${text.slice(0, 24)}…` : text + return ( + + ) +} + +const AgentChatPanel = ({entityId}: {entityId: string}) => { + const sessions = useAtomValue(sessionsListAtom) + const rawActiveId = useAtomValue(activeSessionIdAtom) + const addSession = useSetAtom(addSessionAtom) + const closeSession = useSetAtom(closeSessionAtom) + const renameSession = useSetAtom(renameSessionAtom) + const setActiveSession = useSetAtom(setActiveSessionAtom) + + // Always keep at least one tab. Re-arms when the list drains without double-firing + // under StrictMode. + const seeded = useRef(false) + useEffect(() => { + if (sessions.length === 0 && !seeded.current) { + seeded.current = true + addSession() + } + if (sessions.length > 0) seeded.current = false + }, [sessions.length, addSession]) + + // Tolerate a stale active id (its tab was closed) by falling back to the first tab. + const activeId = sessions.some((s) => s.id === rawActiveId) ? rawActiveId : sessions[0]?.id + + return ( +
+ { + if (action === "add") addSession() + else if (typeof targetKey === "string") closeSession(targetKey) + }} + tabBarExtraContent={{right: }} + items={sessions.map((session, index) => ({ + key: session.id, + closable: sessions.length > 1, + label: ( + renameSession({id: session.id, title})} + /> + ), + children: , + }))} + /> +
+ ) +} + +export default AgentChatPanel diff --git a/web/oss/src/components/AgentChatSlice/assets/agConfig.ts b/web/oss/src/components/AgentChatSlice/assets/agConfig.ts new file mode 100644 index 0000000000..54269ef068 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/agConfig.ts @@ -0,0 +1,104 @@ +import {workflowLatestRevisionQueryAtomFamily} from "@agenta/entities/workflow" +import {getDefaultStore, useAtomValue} from "jotai" + +/** + * Resolve a real `ag_config` + `references` payload from an app's LATEST revision, so the + * app-scoped agent-chat page (`…/apps/[app_id]/agent-chat`) sends the actual workflow + * config instead of a hardcoded stub. + * + * `appId` is the workflow artifact id (route param). `workflowLatestRevisionQueryAtomFamily` + * resolves and fetches the app's latest revision (skipping v0); its `data.parameters` IS the + * `ag_config`, and its id/slug/version fields give us `references` (UUID-guarded, since the + * backend rejects local-draft ids). + * + * `resolveAppAgConfig` reads imperatively so the transport sends the freshest config at send + * time; it returns `null` until the revision has loaded (caller falls back to the stub). + * `useAgConfigStatus` is the reactive companion — it keeps the query warm while the page is + * open and reports readiness for the header badge. + */ + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +const realId = (value: unknown): string | undefined => { + const s = typeof value === "string" ? value : undefined + return s && UUID_RE.test(s) ? s : undefined +} + +const str = (value: unknown): string | undefined => + typeof value === "string" && value ? value : undefined + +interface RevisionLike { + id?: string + slug?: string + version?: number | string | null + workflow_id?: string + workflow_slug?: string + workflow_variant_id?: string + workflow_variant_slug?: string + artifact_id?: string + artifact_slug?: string + variant_id?: string + variant_slug?: string + data?: {parameters?: Record | null} | null +} + +export interface ResolvedAgentConfig { + ag_config: Record + references: Record | null + version: number | null +} + +function buildReferences(rev: RevisionLike): Record | null { + const refs: Record = {} + + const appId = realId(rev.workflow_id) ?? realId(rev.artifact_id) + const appSlug = str(rev.workflow_slug) ?? str(rev.artifact_slug) + if (appId || appSlug) { + refs.application = {...(appId ? {id: appId} : {}), ...(appSlug ? {slug: appSlug} : {})} + } + + const variantId = realId(rev.workflow_variant_id) ?? realId(rev.variant_id) + const variantSlug = str(rev.workflow_variant_slug) ?? str(rev.variant_slug) + if (variantId || variantSlug) { + refs.application_variant = { + ...(variantId ? {id: variantId} : {}), + ...(variantSlug ? {slug: variantSlug} : {}), + } + } + + const revId = realId(rev.id) + const revSlug = str(rev.slug) + const revVersion = typeof rev.version === "number" ? String(rev.version) : str(rev.version) + if (revId || revSlug || revVersion) { + refs.application_revision = { + ...(revId ? {id: revId} : {}), + ...(revSlug ? {slug: revSlug} : {}), + ...(revVersion ? {version: revVersion} : {}), + } + } + + return Object.keys(refs).length > 0 ? refs : null +} + +function fromRevision(rev: RevisionLike | null | undefined): ResolvedAgentConfig | null { + const params = rev?.data?.parameters + if (!rev || !params || Object.keys(params).length === 0) return null + return { + ag_config: params, + references: buildReferences(rev), + version: typeof rev.version === "number" ? rev.version : null, + } +} + +export function resolveAppAgConfig(appId: string | null | undefined): ResolvedAgentConfig | null { + if (!appId) return null + const query = getDefaultStore().get(workflowLatestRevisionQueryAtomFamily(appId)) + return fromRevision(query?.data as RevisionLike | null | undefined) +} + +/** Reactive readiness for the header badge; subscribing also keeps the query warm. */ +export function useAgConfigStatus(appId: string): {ready: boolean; version: number | null} { + const query = useAtomValue(workflowLatestRevisionQueryAtomFamily(appId)) + const resolved = fromRevision(query?.data as RevisionLike | null | undefined) + return {ready: !!resolved, version: resolved?.version ?? null} +} diff --git a/web/oss/src/components/AgentChatSlice/assets/constants.ts b/web/oss/src/components/AgentChatSlice/assets/constants.ts new file mode 100644 index 0000000000..144f480153 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/constants.ts @@ -0,0 +1,29 @@ +import {getEnv} from "@/oss/lib/helpers/dynamicEnv" + +/** + * The two request-contract tracks the slice exposes for the team to compare: + * - `uimessage` (Track A): POST the `useChat` `UIMessage[]` verbatim (parts). No FE + * translation; the service must speak AI SDK parts. + * - `agenta` (Track B): adapt to Agenta's existing `{role, content}` message shape (the + * contract `chat.py`/`completion.py` already parse), with approvals in `tool_approvals`. + * + * The *response* stream (text + tools + approval + trace) is identical for both; only the + * outgoing request body differs. + */ +export type AgentChatTrack = "uimessage" | "agenta" + +const API_BASE = getEnv("NEXT_PUBLIC_AGENT_CHAT_API") || "http://localhost:8000/api/agent/chat" + +/** Streaming endpoint per track. Track B appends `-agenta` to the base path. */ +export const trackApi = (track: AgentChatTrack): string => + track === "agenta" ? `${API_BASE}-agenta` : API_BASE + +/** Default track on first load. Override with `NEXT_PUBLIC_AGENT_CHAT_TRACK=agenta`. */ +export const DEFAULT_TRACK: AgentChatTrack = + (getEnv("NEXT_PUBLIC_AGENT_CHAT_TRACK") || "").toLowerCase() === "agenta" + ? "agenta" + : "uimessage" + +/** Whether the agent chat slice page is enabled. Feature-flagged, off by default. */ +export const isAgentChatSliceEnabled = (): boolean => + (getEnv("NEXT_PUBLIC_AGENT_CHAT_SLICE") || "").toLowerCase() === "true" diff --git a/web/oss/src/components/AgentChatSlice/assets/files.ts b/web/oss/src/components/AgentChatSlice/assets/files.ts new file mode 100644 index 0000000000..8e710d0a12 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/files.ts @@ -0,0 +1,45 @@ +import type {FileUIPart, UIMessage} from "ai" + +/** + * Multi-modality helpers for the agent chat slice. Attachments are kept entirely on the + * client: there is no upload server, so a selected file is read into a `data:` URL and + * sent inline as an AI SDK v6 `file` part (`{type, mediaType, filename, url}`). The service + * receives the bytes in the request body — same channel as the text. + */ + +export type FileKind = "image" | "audio" | "video" | "file" + +/** Map an IANA media type to the `FileCard` `type` / a render branch. */ +export const fileKind = (mediaType: string): FileKind => { + if (mediaType.startsWith("image/")) return "image" + if (mediaType.startsWith("audio/")) return "audio" + if (mediaType.startsWith("video/")) return "video" + return "file" +} + +/** Read one `File` into a `data:` URL `file` part. */ +const fileToPart = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onerror = () => reject(reader.error) + reader.onload = () => + resolve({ + type: "file", + mediaType: file.type || "application/octet-stream", + filename: file.name, + url: reader.result as string, // data:;base64,<...> + }) + reader.readAsDataURL(file) + }) + +/** Convert picked `File`s into `file` parts for `sendMessage({text, files})`. */ +export const filesToParts = (files: File[]): Promise => + Promise.all(files.map(fileToPart)) + +/** The `file` parts of a message, in order. */ +export const fileParts = (message: UIMessage): FileUIPart[] => + message.parts.filter((p) => p.type === "file") as FileUIPart[] + +/** A readable label for a file part (filename, else the tail of its URL). */ +export const filePartName = (part: FileUIPart): string => + part.filename || part.url.split("/").pop()?.split("?")[0] || "file" diff --git a/web/oss/src/components/AgentChatSlice/assets/loadSession.ts b/web/oss/src/components/AgentChatSlice/assets/loadSession.ts new file mode 100644 index 0000000000..2ed5c10296 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/loadSession.ts @@ -0,0 +1,25 @@ +import type {UIMessage} from "ai" + +/** + * Server-side hydration seam for a session's conversation. + * + * Today this returns `null`: the agent service wires a `NoopSessionStore`, so the backend does + * NOT own message history — the only record of a conversation's content is this browser's + * localStorage (`sessionMessagesAtom`). So opening a session from a deep link / observability + * trace can only render content for sessions that originated in THIS browser. + * + * When the backend gains a real `SessionStore` (DB-backed message history), wire the call here: + * + * POST {AGENT_SERVICE}/services/agent/v0/load-session + * ?project_id=&application_id= + * body: { session_id } + * → returns the stored turns; map them to v6 `UIMessage[]` (reuse the vercel messages + * adapter's shape) and return them here. The caller writes them into `sessionMessagesAtom` + * before opening the tab, so the conversation seeds from server history. + * + * Returning `null` means "no server history available" — the caller falls back to whatever is + * already in localStorage. + */ +export const loadSessionMessages = async (_sessionId: string): Promise => { + return null +} diff --git a/web/oss/src/components/AgentChatSlice/assets/markdown.tsx b/web/oss/src/components/AgentChatSlice/assets/markdown.tsx new file mode 100644 index 0000000000..40aeeaeed4 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/markdown.tsx @@ -0,0 +1,108 @@ +import type {ReactNode} from "react" + +import {XMarkdown} from "@ant-design/x-markdown" +import Latex from "@ant-design/x-markdown/plugins/Latex" +import {PrismAsync as SyntaxHighlighter} from "react-syntax-highlighter" +import {oneDark} from "react-syntax-highlighter/dist/esm/styles/prism" + +// Dark-mode-aware markdown styling. `min-w-0` + `max-w-full` + the per-element width guards +// keep long lines / code blocks from widening their container; code blocks scroll within their +// own box instead. XMarkdown ships NO default element CSS, so every block we want styled is +// listed here explicitly. +export const MD_CLASS = + "min-w-0 max-w-full overflow-hidden break-words text-sm leading-relaxed " + + "[&_a]:text-colorPrimary [&_a]:underline [&_a]:break-all [&_p]:my-1 [&_p]:break-words " + + "[&_ul]:my-1 [&_ul]:pl-5 [&_ol]:my-1 [&_ol]:pl-5 [&_li]:my-0.5 [&_code]:rounded " + + "[&_code]:bg-colorFillTertiary [&_code]:px-1 [&_code]:break-words [&_pre]:bg-colorFillTertiary " + + "[&_pre]:p-2 [&_pre]:rounded [&_pre]:max-w-full [&_pre]:min-w-0 [&_pre]:overflow-x-auto " + + // Tables: real borders + padding, a quiet header, and `break-normal` cells so text wraps at + // spaces instead of snapping mid-word ("PostH og"). Full-width within the bubble. + "[&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_table]:text-xs " + + "[&_th]:border [&_th]:border-solid [&_th]:border-colorBorderSecondary [&_th]:bg-colorFillTertiary " + + "[&_th]:px-2.5 [&_th]:py-1.5 [&_th]:text-left [&_th]:align-top [&_th]:font-medium [&_th]:break-normal " + + "[&_td]:border [&_td]:border-solid [&_td]:border-colorBorderSecondary " + + "[&_td]:px-2.5 [&_td]:py-1.5 [&_td]:align-top [&_td]:break-normal " + + // Headings — compact for a chat bubble (browser defaults are huge), descending weight/size. + "[&_h1]:mt-3 [&_h1]:mb-1 [&_h1]:text-base [&_h1]:font-semibold [&_h1]:leading-snug " + + "[&_h2]:mt-3 [&_h2]:mb-1 [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:leading-snug " + + "[&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:text-sm [&_h3]:font-semibold " + + "[&_h4]:mt-2 [&_h4]:mb-0.5 [&_h4]:text-xs [&_h4]:font-semibold " + + "[&_h5]:mt-2 [&_h5]:mb-0.5 [&_h5]:text-xs [&_h5]:font-semibold " + + "[&_h6]:mt-2 [&_h6]:mb-0.5 [&_h6]:text-xs [&_h6]:font-medium [&_h6]:text-colorTextSecondary " + + // Blockquote — a quiet left-ruled aside (kill the browser's 40px indent). + "[&_blockquote]:my-2 [&_blockquote]:mx-0 [&_blockquote]:border-0 [&_blockquote]:border-l-2 " + + "[&_blockquote]:border-solid [&_blockquote]:border-colorBorderSecondary [&_blockquote]:pl-3 " + + "[&_blockquote]:text-colorTextSecondary [&_blockquote]:italic " + + // Rule, images, emphasis, strikethrough, and task-list checkboxes. + "[&_hr]:my-3 [&_hr]:border-0 [&_hr]:border-t [&_hr]:border-solid [&_hr]:border-colorBorderSecondary " + + "[&_img]:my-2 [&_img]:max-w-full [&_img]:rounded " + + "[&_strong]:font-semibold [&_em]:italic [&_del]:line-through " + + "[&_li:has(input)]:list-none [&_input]:mr-1.5 [&_input]:align-middle " + + // Trim the outer edges so the bubble padding isn't doubled by leading/trailing margins. + "[&>:first-child]:!mt-0 [&>:last-child]:!mb-0" + +/** Math support ($…$ / $$…$$) via KaTeX — registered once as a marked extension. */ +const LATEX_CONFIG = {extensions: Latex()} + +/** Flatten a code element's children (string / text nodes) to the raw source. */ +const childrenToText = (children: ReactNode): string => { + if (typeof children === "string") return children + if (typeof children === "number") return String(children) + if (Array.isArray(children)) return children.map(childrenToText).join("") + if (children && typeof children === "object" && "props" in children) { + return childrenToText((children as {props?: {children?: ReactNode}}).props?.children) + } + return "" +} + +/** + * Code renderer: inline `code` keeps the styled chip; a fenced block gets Prism syntax + * highlighting (language-on-demand via PrismAsync, oneDark theme). XMarkdown supplies `block` + * and `lang` (the fence info string) so we don't have to parse `className`. + */ +const CodeBlock = ({ + block, + lang, + className, + children, +}: { + block?: boolean + lang?: string + className?: string + children?: ReactNode +}) => { + if (!block) return {children} + return ( + + {childrenToText(children).replace(/\n$/, "")} + + ) +} + +/** Unwrap the markdown `
` — the highlighted block owns its own container. */
+const PreUnwrap = ({children}: {children?: ReactNode}) => <>{children}
+
+/** Shared markdown renderer for the slice — used by message bubbles and the composer live
+ * preview, so both render identically. `className` appends to `MD_CLASS` so callers can tweak
+ * size/color (e.g. the muted reasoning block) without forking the renderer. */
+const Markdown = ({content, className}: {content: string; className?: string}) => (
+    
+)
+
+export default Markdown
diff --git a/web/oss/src/components/AgentChatSlice/assets/rewind.ts b/web/oss/src/components/AgentChatSlice/assets/rewind.ts
new file mode 100644
index 0000000000..eb33bba849
--- /dev/null
+++ b/web/oss/src/components/AgentChatSlice/assets/rewind.ts
@@ -0,0 +1,34 @@
+import type {UIMessage} from "ai"
+
+/**
+ * Tools with no external side effect — safe to rewind/retry past silently. v1 hardcodes
+ * this; the principled source is a `readOnly` flag on the tool spec (see
+ * `docs/design/agent-workflows/agent-chat-rewind.md`). Everything not listed here is treated
+ * as potentially side-effecting, so the user is warned before rewinding past it.
+ */
+export const READ_ONLY_TOOLS = new Set(["search_docs"])
+
+/** Concatenated text of a message's text parts. */
+export const messageText = (message: UIMessage): string =>
+    message.parts
+        .filter((p) => p.type === "text")
+        .map((p) => (p as {text: string}).text)
+        .join("")
+
+/**
+ * Names of side-effecting tools that ALREADY produced output within `messages` — i.e. real
+ * actions a rewind cannot undo (e.g. a sent email). Read-only tools are ignored, and tool
+ * calls that never ran (still awaiting approval, denied, errored) are ignored.
+ */
+export const sideEffectingToolsInRange = (messages: UIMessage[]): string[] => {
+    const names = new Set()
+    for (const message of messages) {
+        for (const part of message.parts) {
+            if (!part.type.startsWith("tool-")) continue
+            const ran = (part as {state?: string}).state === "output-available"
+            const name = part.type.replace(/^tool-/, "")
+            if (ran && !READ_ONLY_TOOLS.has(name)) names.add(name)
+        }
+    }
+    return [...names]
+}
diff --git a/web/oss/src/components/AgentChatSlice/assets/toAgentaMessage.ts b/web/oss/src/components/AgentChatSlice/assets/toAgentaMessage.ts
new file mode 100644
index 0000000000..28dbf728fa
--- /dev/null
+++ b/web/oss/src/components/AgentChatSlice/assets/toAgentaMessage.ts
@@ -0,0 +1,142 @@
+import type {FileUIPart, ToolUIPart, UIMessage} from "ai"
+
+import {fileKind, filePartName} from "./files"
+
+/**
+ * Track B adapter — the cost of keeping the request contract aligned with Agenta's
+ * existing services.
+ *
+ * `useChat` owns the conversation as `UIMessage[]` (typed parts). The existing Agenta
+ * runtime (`chat.py`, `completion.py`, the execution-item builder) speaks OpenAI/ACP-style
+ * `{role, content}` messages with `tool_calls` / `tool` result messages — NOT AI SDK parts.
+ * This function translates one into the other so the slice can POST the shape those
+ * services already parse.
+ *
+ * Two things the Agenta message contract has no native slot for, and what we do with them:
+ *   - **reasoning parts** → dropped (no reasoning field in `{role, content}`).
+ *   - **approval decisions** → there is no per-tool-call approval field on the Agenta
+ *     request, so the decision is surfaced out-of-band in `tool_approvals`. This is a
+ *     net-new convention Track B has to propose; it is exactly the seam to evaluate.
+ *
+ * Track A (the other option) skips this file entirely: `useChat`'s default transport posts
+ * the `UIMessage[]` verbatim, and the service is expected to speak parts.
+ */
+
+export interface AgentaToolCall {
+    id: string
+    type: "function"
+    function: {name: string; arguments: string}
+}
+
+/**
+ * OpenAI-style multimodal content parts. A message with attachments serializes `content`
+ * as this array instead of a plain string (images → `image_url`, other files → `file` with
+ * the bytes inline as a data URL). Like `tool_approvals`, the exact multimodal shape Track B
+ * sends is a net-new convention to validate against the backend.
+ */
+export type AgentaContentPart =
+    | {type: "text"; text: string}
+    | {type: "image_url"; image_url: {url: string}}
+    | {type: "file"; file: {filename: string; file_data: string}}
+
+export interface AgentaMessage {
+    role: string
+    content: string | AgentaContentPart[]
+    tool_calls?: AgentaToolCall[]
+    tool_call_id?: string
+    name?: string
+}
+
+export interface AgentaToolApproval {
+    tool_call_id: string
+    tool_name: string
+    approved: boolean
+    input?: unknown
+}
+
+export interface AgentaRequestMessages {
+    messages: AgentaMessage[]
+    tool_approvals: AgentaToolApproval[]
+}
+
+const toolName = (part: ToolUIPart) => part.type.replace(/^tool-/, "")
+
+const textOf = (message: UIMessage): string =>
+    message.parts
+        .filter((p) => p.type === "text")
+        .map((p) => (p as {text: string}).text)
+        .join("")
+
+const filePartToContent = (part: FileUIPart): AgentaContentPart =>
+    fileKind(part.mediaType) === "image"
+        ? {type: "image_url", image_url: {url: part.url}}
+        : {type: "file", file: {filename: filePartName(part), file_data: part.url}}
+
+/**
+ * Message content for the Agenta request: a plain string when there are no attachments
+ * (the common case), or an OpenAI-style multimodal parts array when the message carries
+ * `file` parts (text first, then one entry per attachment).
+ */
+const contentOf = (message: UIMessage): string | AgentaContentPart[] => {
+    const files = message.parts.filter((p) => p.type === "file") as FileUIPart[]
+    const text = textOf(message)
+    if (files.length === 0) return text
+    return [...(text ? [{type: "text" as const, text}] : []), ...files.map(filePartToContent)]
+}
+
+/** Convert the `useChat` `UIMessage[]` into the Agenta `{role, content}` request shape. */
+export const toAgentaMessages = (uiMessages: UIMessage[]): AgentaRequestMessages => {
+    const messages: AgentaMessage[] = []
+    const toolApprovals: AgentaToolApproval[] = []
+
+    for (const ui of uiMessages) {
+        const toolParts = ui.parts.filter((p) => p.type.startsWith("tool-")) as ToolUIPart[]
+
+        const toolCalls: AgentaToolCall[] = toolParts.map((tp) => ({
+            id: tp.toolCallId,
+            type: "function",
+            function: {
+                name: toolName(tp),
+                arguments: JSON.stringify(tp.input ?? {}),
+            },
+        }))
+
+        messages.push({
+            role: ui.role,
+            content: contentOf(ui),
+            ...(toolCalls.length ? {tool_calls: toolCalls} : {}),
+        })
+
+        // Resolved tool calls become OpenAI-style `tool` result messages.
+        for (const tp of toolParts) {
+            if (tp.state === "output-available") {
+                messages.push({
+                    role: "tool",
+                    tool_call_id: tp.toolCallId,
+                    name: toolName(tp),
+                    content: JSON.stringify(tp.output ?? null),
+                })
+            } else if (tp.state === "output-denied") {
+                messages.push({
+                    role: "tool",
+                    tool_call_id: tp.toolCallId,
+                    name: toolName(tp),
+                    content: JSON.stringify({status: "denied"}),
+                })
+            }
+
+            // Pending approval decision → out-of-band side channel.
+            if (tp.state === "approval-responded") {
+                const approval = (tp as {approval?: {approved?: boolean}}).approval
+                toolApprovals.push({
+                    tool_call_id: tp.toolCallId,
+                    tool_name: toolName(tp),
+                    approved: Boolean(approval?.approved),
+                    input: tp.input,
+                })
+            }
+        }
+    }
+
+    return {messages, tool_approvals: toolApprovals}
+}
diff --git a/web/oss/src/components/AgentChatSlice/assets/trace.ts b/web/oss/src/components/AgentChatSlice/assets/trace.ts
new file mode 100644
index 0000000000..abda9430f2
--- /dev/null
+++ b/web/oss/src/components/AgentChatSlice/assets/trace.ts
@@ -0,0 +1,64 @@
+import type {UIMessage} from "ai"
+
+/**
+ * The custom `data-trace` part the service emits: `{type: "data-trace", data: {...}}`.
+ * The service sends both a `traceId` (preferred — `openTraceDrawerAtom` wants an id) and a
+ * `url` (human link). We parse the id out of the url as a fallback for older emitters that
+ * only send `{url}` (the original RAG_QA example did).
+ */
+interface TracePartData {
+    traceId?: string
+    url?: string
+}
+
+const parseTraceIdFromUrl = (url?: string): string | undefined => {
+    if (!url) return undefined
+    const segments = url.split("?")[0].split("/").filter(Boolean)
+    return segments[segments.length - 1] || undefined
+}
+
+/**
+ * Extract the trace id for a message. Prefers `message.metadata.traceId` (the RFC-aligned
+ * channel — the service sets it via `messageMetadata` on the `start`/`finish` parts), and
+ * falls back to the custom `data-trace` part for emitters that only send that.
+ */
+export const getMessageTraceId = (message: UIMessage): string | undefined => {
+    const metaTraceId = (message.metadata as {traceId?: string} | undefined)?.traceId
+    if (metaTraceId) return metaTraceId
+
+    const tracePart = message.parts.find((p) => p.type === "data-trace") as
+        | {type: "data-trace"; data?: TracePartData}
+        | undefined
+    if (!tracePart?.data) return undefined
+    return tracePart.data.traceId || parseTraceIdFromUrl(tracePart.data.url)
+}
+
+/** Token/cost fields in `ExecutionMetricsDisplay`'s shape. */
+export interface MessageUsageMetrics {
+    promptTokens?: number
+    completionTokens?: number
+    totalTokens?: number
+    totalCost?: number
+}
+
+/**
+ * Usage (tokens + cost) the service stamps onto `message.metadata.usage` via the
+ * `finish` part's messageMetadata (`{input, output, total, cost}`), mapped to the
+ * metrics-display field names. The trace supplies latency; this supplies tokens/cost
+ * (the agent-run trace summary doesn't surface them on the Pi/local path).
+ */
+export const getMessageUsage = (message: UIMessage): MessageUsageMetrics | undefined => {
+    const usage = (message.metadata as {usage?: Record} | undefined)?.usage
+    if (!usage || typeof usage !== "object") return undefined
+    const num = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined)
+    const out: MessageUsageMetrics = {}
+    const input = num(usage.input)
+    const output = num(usage.output)
+    const total = num(usage.total)
+    const cost = num(usage.cost)
+    if (input !== undefined) out.promptTokens = input
+    if (output !== undefined) out.completionTokens = output
+    if (total !== undefined) out.totalTokens = total
+    if (cost !== undefined) out.totalCost = cost
+    return Object.keys(out).length > 0 ? out : undefined
+}
diff --git a/web/oss/src/components/AgentChatSlice/assets/transport.ts b/web/oss/src/components/AgentChatSlice/assets/transport.ts
new file mode 100644
index 0000000000..daa639aab7
--- /dev/null
+++ b/web/oss/src/components/AgentChatSlice/assets/transport.ts
@@ -0,0 +1,132 @@
+import {projectIdAtom} from "@agenta/shared/state"
+import {DefaultChatTransport, type UIMessage} from "ai"
+import {getDefaultStore} from "jotai"
+
+import {getJWT} from "@/oss/services/api"
+
+import {resolveAppAgConfig} from "./agConfig"
+import {type AgentChatTrack, trackApi} from "./constants"
+import {toAgentaMessages} from "./toAgentaMessage"
+
+/**
+ * Transport for the agent chat slice (contract v1), parameterized by request-contract
+ * **track**. Both tracks consume the same v6 UI Message Stream response — only the
+ * outgoing request body shape differs (see ./constants and ./toAgentaMessage).
+ *
+ * The request is built the way the playground execution pipeline builds it, so the page
+ * can hit a real authenticated backend:
+ *  - **Auth:** `Authorization: Bearer ` from `getJWT()` (omitted when unauthenticated,
+ *    so the credential-free example backend still works).
+ *  - **Query params:** `application_id` (the app id) and `project_id` (the current
+ *    project, only sent alongside auth — mirroring `executionItems.ts`).
+ *  - **Body:** the agent-protocol envelope — `session_id` + `references` at the top level,
+ *    and `data: {messages, parameters}` nested (the config resolved from the app's LATEST
+ *    revision via `resolveAppAgConfig`, else a stub). `parameters` is the stored workflow
+ *    config (what the backend reads as `data.parameters`); `references` lines up at the top
+ *    level. This matches Mahmoud's BE contract (2026-06-19).
+ *
+ * **Track A (`uimessage`)** — POST the `UIMessage[]` verbatim. The service speaks AI SDK
+ * parts; the approval decision is inside the assistant message's tool part. Zero FE
+ * translation (JP's "1:1 to UIMessage parts, no translation layer").
+ *
+ * **Track B (`agenta`)** — adapt to Agenta's `{role, content}` + `tool_calls` shape via
+ * `toAgentaMessages`, with the approval decision in a `tool_approvals` side field. Uniform
+ * backend contract across workflow types, at the cost of a FE translation layer.
+ */
+const stubConfig = () => ({
+    parameters: {
+        prompt: {
+            messages: [{role: "system", content: "You are a helpful agent."}],
+            llm_config: {model: "gpt-4o-mini", tools: []},
+        },
+        harness: "pi",
+        sandbox: "local",
+    },
+    references: {
+        application: null,
+        application_variant: null,
+        application_revision: null,
+    },
+})
+
+/**
+ * Real config from the app's latest revision when `appId` is set and loaded; else the stub.
+ * Returns `{parameters, references}`: `parameters` is the agent config the backend reads as
+ * `data.parameters`. `harness`/`sandbox` (agent-specific, not part of a stored workflow
+ * config) are defaulted but never override values the resolved config already carries.
+ */
+const configFor = (appId?: string | null) => {
+    const resolved = resolveAppAgConfig(appId)
+    if (!resolved) return stubConfig()
+    return {
+        parameters: {harness: "pi", sandbox: "local", ...resolved.ag_config},
+        references: resolved.references,
+    }
+}
+
+const withQuery = (url: string, params: Record): string => {
+    const qs = new URLSearchParams()
+    for (const [key, value] of Object.entries(params)) {
+        if (value) qs.set(key, value)
+    }
+    const suffix = qs.toString()
+    return suffix ? `${url}${url.includes("?") ? "&" : "?"}${suffix}` : url
+}
+
+/** Per-request auth header + URL (with `application_id`/`project_id` query params), built
+ * the way the playground pipeline builds them so the page can hit a real backend. */
+async function requestMeta(track: AgentChatTrack, appId?: string | null) {
+    const jwt = await getJWT()
+    // `Accept: text/event-stream` makes the agent `/messages` endpoint serve the v6 SSE
+    // stream useChat consumes; without it the endpoint negotiates down to batch JSON
+    // (the AI-SDK transport sets no Accept), which useChat can't render.
+    const headers: Record = {Accept: "text/event-stream"}
+    if (jwt) headers.Authorization = `Bearer ${jwt}`
+    const projectId = getDefaultStore().get(projectIdAtom) || undefined
+    const api = withQuery(trackApi(track), {
+        application_id: appId || undefined,
+        // Mirror executionItems.ts: project_id only travels alongside auth.
+        project_id: jwt ? projectId : undefined,
+    })
+    return {api, headers}
+}
+
+export function createAgentChatTransport(track: AgentChatTrack, appId?: string | null) {
+    return new DefaultChatTransport({
+        api: trackApi(track),
+        prepareSendMessagesRequest: async ({messages, id, body}) => {
+            const {parameters, references} = configFor(appId)
+            const {api, headers} = await requestMeta(track, appId)
+
+            if (track === "agenta") {
+                // Track B: FE adapts down to the existing Agenta message contract. Same
+                // envelope; the approval decision stays in the top-level `tool_approvals`
+                // side field (the Agenta message shape has no per-tool approval slot).
+                const {messages: agentaMessages, tool_approvals} = toAgentaMessages(messages)
+                return {
+                    api,
+                    headers,
+                    body: {
+                        session_id: id,
+                        references,
+                        tool_approvals,
+                        data: {messages: agentaMessages, parameters},
+                        ...body,
+                    },
+                }
+            }
+
+            // Track A: post the `UIMessage[]` verbatim — the service reads `data.messages`.
+            return {
+                api,
+                headers,
+                body: {
+                    session_id: id,
+                    references,
+                    data: {messages, parameters},
+                    ...body,
+                },
+            }
+        },
+    })
+}
diff --git a/web/oss/src/components/AgentChatSlice/components/AgentChatConversation.tsx b/web/oss/src/components/AgentChatSlice/components/AgentChatConversation.tsx
new file mode 100644
index 0000000000..1889fc99f5
--- /dev/null
+++ b/web/oss/src/components/AgentChatSlice/components/AgentChatConversation.tsx
@@ -0,0 +1,283 @@
+import {useEffect, useMemo, useRef, useState} from "react"
+
+import {useChat} from "@ai-sdk/react"
+import {Attachments, Bubble, Sender} from "@ant-design/x"
+import {Paperclip} from "@phosphor-icons/react"
+import {lastAssistantMessageIsCompleteWithApprovalResponses, type UIMessage} from "ai"
+import {Alert, Button, Modal, Tag, Tooltip, Typography, type UploadFile} from "antd"
+import {useSetAtom, useStore} from "jotai"
+
+import {useAgConfigStatus} from "../assets/agConfig"
+import {type AgentChatTrack, trackApi} from "../assets/constants"
+import {filesToParts} from "../assets/files"
+import {messageText, sideEffectingToolsInRange} from "../assets/rewind"
+import {createAgentChatTransport} from "../assets/transport"
+import {persistSessionMessagesAtom, sessionMessagesAtom} from "../state/sessions"
+
+import AgentMessage from "./AgentMessage"
+
+const {Text} = Typography
+
+/** Reactive badge: shows whether the real per-revision `ag_config` has loaded (and keeps
+ * the latest-revision query warm so the transport can read it at send time). */
+const ConfigBadge = ({appId}: {appId: string}) => {
+    const {ready, version} = useAgConfigStatus(appId)
+    return ready ? (
+        
+            config: revision{version != null ? ` v${version}` : ""}
+        
+    ) : (
+        config: loading… (stub until ready)
+    )
+}
+
+/**
+ * One `useChat` conversation for a single request-contract track, rendered with Ant Design X
+ * (`Bubble` per message + `Sender` composer). The parent remounts this (via `key={track}`)
+ * when the track changes, so each track gets a clean session and a fresh transport. The
+ * streamed response + rendering are identical across tracks; only the outgoing request body
+ * differs (watch the Network tab to compare).
+ *
+ * When `appId` is set (the page is app-scoped), the transport sends the real `ag_config` +
+ * `references` resolved from that app's latest revision; otherwise it falls back to a stub.
+ */
+const AgentChatConversation = ({
+    sessionId,
+    track,
+    appId,
+}: {
+    sessionId: string
+    track: AgentChatTrack
+    appId: string | null
+}) => {
+    const store = useStore()
+    const persistMessages = useSetAtom(persistSessionMessagesAtom)
+    const [input, setInput] = useState("")
+    // Pending attachments for the next message. Kept client-side only: `beforeUpload`
+    // returns false so antd never uploads; we read each `originFileObj` into a data: URL at
+    // send time (see `filesToParts`).
+    const [files, setFiles] = useState([])
+    const [attachmentsOpen, setAttachmentsOpen] = useState(false)
+    // Seed once from the persisted store (read imperatively so our own writes below don't
+    // feed back). The session id is owned by the tab and travels to the backend as
+    // `session_id`; the `:${track}` in the parent's key remounts on a dev track flip, which
+    // rehydrates from here with a fresh transport.
+    const [initialMessages] = useState(() => store.get(sessionMessagesAtom)[sessionId] ?? [])
+    const senderRef = useRef>(null)
+    const dropContainerRef = useRef(null)
+    const transport = useMemo(() => createAgentChatTransport(track, appId), [track, appId])
+
+    const {
+        messages,
+        sendMessage,
+        status,
+        stop,
+        regenerate,
+        setMessages,
+        addToolApprovalResponse,
+        error,
+    } = useChat({
+        id: sessionId,
+        messages: initialMessages,
+        transport,
+        sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
+        onError: (err) => {
+            console.error("[AgentChatSlice] useChat error:", err)
+        },
+    })
+
+    const busy = status === "submitted" || status === "streaming"
+
+    // Persist the conversation whenever its stream settles (skip mid-stream so we don't
+    // write on every token). Covers send (status "submitted"), finish/error ("ready"/
+    // "error"), and clear/rewind (setMessages → "ready").
+    useEffect(() => {
+        if (status === "streaming") return
+        persistMessages({id: sessionId, messages})
+    }, [messages, status, sessionId, persistMessages])
+
+    const handleSubmit = async (text: string) => {
+        const trimmed = text.trim()
+        const fileObjs = files
+            .map((f) => f.originFileObj as File | undefined)
+            .filter((f): f is File => Boolean(f))
+        if ((!trimmed && fileObjs.length === 0) || busy) return
+        // Read attachments into data: URL `file` parts; `sendMessage` adds them to the
+        // outgoing user message alongside the text part.
+        const fileParts = fileObjs.length ? await filesToParts(fileObjs) : undefined
+        sendMessage(
+            fileParts
+                ? trimmed
+                    ? {text: trimmed, files: fileParts}
+                    : {files: fileParts}
+                : {text: trimmed},
+        )
+        setInput("")
+        setFiles([])
+        setAttachmentsOpen(false)
+    }
+
+    /**
+     * Rewind the conversation to `message` (truncate-in-place). A user turn drops it +
+     * everything after and prefills the composer with its text to edit/resend; an assistant
+     * turn re-runs via `regenerate`. Confirms first if the dropped range contains a tool that
+     * already ran with a side effect (a rewind can't undo it).
+     */
+    const handleRewind = (message: UIMessage) => {
+        if (busy) return
+        const idx = messages.findIndex((m) => m.id === message.id)
+        if (idx < 0) return
+        const isUser = message.role === "user"
+        // Everything from here on is dropped/re-run; already-executed side effects in this
+        // tail (incl. the assistant turn's own tools, which regenerate re-fires) won't undo.
+        const sideEffects = sideEffectingToolsInRange(messages.slice(idx))
+
+        const run = () => {
+            if (isUser) {
+                setMessages(messages.slice(0, idx))
+                setInput(messageText(message))
+                // Focus the composer so the user can edit the restored text immediately.
+                requestAnimationFrame(() => senderRef.current?.focus())
+            } else {
+                regenerate({messageId: message.id})
+            }
+        }
+
+        if (sideEffects.length > 0) {
+            Modal.confirm({
+                title: "Rewind past a tool that already ran?",
+                content: `${sideEffects.join(", ")} already executed. Rewinding re-runs the conversation from here but will NOT undo it.`,
+                okText: "Rewind anyway",
+                okButtonProps: {danger: true},
+                cancelText: "Cancel",
+                onOk: run,
+            })
+        } else {
+            run()
+        }
+    }
+
+    return (
+        
+
+
+ + POST {trackApi(track)} + + + session: {sessionId} + +
+
+ {appId && } + {messages.length > 0 && ( + setMessages([])} + > + Clear + + )} +
+
+ + {error && ( + + )} + +
+ {messages.length === 0 && ( +
+ Ask a question to start the agent conversation. +
+ )} + {messages.map((message, index) => ( + handleRewind(message)} + onApprovalResponse={addToolApprovalResponse} + /> + ))} + {status === "submitted" && messages[messages.length - 1]?.role !== "assistant" && ( + + )} +
+ + { + setFiles((prev) => [ + ...prev, + ...Array.from(pasted).map((file) => ({ + uid: `${file.name}-${file.lastModified}-${file.size}`, + name: file.name, + status: "done" as const, + originFileObj: file as UploadFile["originFileObj"], + })), + ]) + setAttachmentsOpen(true) + }} + prefix={ + +
+ ) +} + +export default AgentChatConversation diff --git a/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx new file mode 100644 index 0000000000..f12fb46245 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx @@ -0,0 +1,357 @@ +import {memo, useEffect, useRef, useState} from "react" + +import {traceDataSummaryAtomFamily} from "@agenta/entities/loadable" +import {ExecutionMetricsDisplay} from "@agenta/ui/components/presentational" +import {Actions, Bubble, FileCard, type ActionsProps} from "@ant-design/x" +import { + ArrowUUpLeft, + Brain, + CaretRight, + Copy, + Robot, + TreeStructure, + User, + XCircle, +} from "@phosphor-icons/react" +import type {FileUIPart, ReasoningUIPart, ToolUIPart, UIMessage} from "ai" +import {Avatar, Typography} from "antd" +import {useAtomValue, useSetAtom} from "jotai" + +import {openTraceDrawerAtom} from "@/oss/components/SharedDrawers/TraceDrawer/store/traceDrawerStore" + +import {fileKind, filePartName} from "../assets/files" +import Markdown from "../assets/markdown" +import {getMessageTraceId, getMessageUsage, type MessageUsageMetrics} from "../assets/trace" + +import ToolPart from "./ToolPart" + +const {Text} = Typography + +/** Cost / tokens / latency for a message, read from its trace (same data + component the + * playground and trace drawer use). */ +const TraceMetrics = ({traceId, usage}: {traceId: string; usage?: MessageUsageMetrics}) => { + const summary = useAtomValue(traceDataSummaryAtomFamily(traceId)) + // Latency comes from the trace; tokens/cost come from the streamed message usage + // (the agent-run trace summary doesn't surface them on the Pi/local path). Usage + // wins where both exist so the figures match what the model actually reported. + const metrics = {...summary.metrics, ...usage} + return +} + +interface AgentMessageProps { + message: UIMessage + busy: boolean + /** This is the last message AND the conversation is streaming — i.e. the one being + * generated right now. Only it shows the loading state; settled turns never do. */ + isStreaming?: boolean + onRewind: () => void + onApprovalResponse: (args: {id: string; approved: boolean}) => void +} + +const isToolPart = (type: string) => type.startsWith("tool-") || type === "dynamic-tool" + +/** + * Collapsible reasoning ("thinking") block. While the model is reasoning (`state === + * "streaming"`) it auto-expands so the thoughts stream live; once done it auto-collapses to a + * "Thought" toggle — click to re-expand. A manual toggle sticks (we stop auto-driving it). + */ +const ReasoningPart = ({text, streaming}: {text: string; streaming: boolean}) => { + const [expanded, setExpanded] = useState(streaming) + const userToggled = useRef(false) + + useEffect(() => { + if (!userToggled.current) setExpanded(streaming) + }, [streaming]) + + return ( +
+ + {/* Smooth height collapse (grid 0fr→1fr) — same trick as the composer attachments, + so the thought folds away instead of popping. Markdown-rendered + muted, no + border (the reasoning reads as a quiet aside under the toggle, not a boxed card). */} +
+
+
+ +
+
+
+
+ ) +} + +const avatarFor = (isUser: boolean) => ( + : } /> +) + +/** + * Read-only renderer for one agent conversation message, rendered inside an Ant Design X + * `Bubble`. Walks `message.parts` in order (text → markdown, reasoning, tool calls + + * approvals, sources) for the bubble body, and puts the per-message action row in the + * footer. While an assistant message has no content yet, the bubble shows the loading state. + */ +const AgentMessage = ({ + message, + busy, + isStreaming = false, + onRewind, + onApprovalResponse, +}: AgentMessageProps) => { + const openTraceDrawer = useSetAtom(openTraceDrawerAtom) + const isUser = message.role === "user" + + const traceId = getMessageTraceId(message) + const usage = getMessageUsage(message) + // A failed run (e.g. a quota error the runner swallowed into an empty turn) lands as an + // error on the trace; read it so the bubble can render as a failure. + const traceError = useAtomValue(traceDataSummaryAtomFamily(traceId ?? null)).error + const fullText = message.parts + .filter((p) => p.type === "text") + .map((p) => (p as {text: string}).text) + .join("") + const sources = message.parts.filter((p) => p.type === "source-url") as { + type: "source-url" + url: string + title?: string + }[] + + // "Answer" = anything the user is meant to read as a reply (text / tool / file / source). + // Reasoning alone is NOT an answer — a turn that only thought hasn't responded. + const hasAnswer = message.parts.some( + (p) => + (p.type === "text" && (p as {text?: string}).text) || + isToolPart(p.type) || + p.type === "file" || + p.type === "source-url", + ) + const hasReasoning = message.parts.some( + (p) => p.type === "reasoning" && (p as {text?: string}).text, + ) + const hasContent = hasAnswer || hasReasoning + + // A settled assistant turn (NOT the one being generated) with no answer — only a thought, + // or nothing — means the model ended without responding. Surface it so the bubble doesn't + // read as frozen/broken. Keyed on `isStreaming`, not the conversation-level `busy`, so + // earlier answer-less turns don't all light up while a later turn streams. + const noResponse = !isUser && !isStreaming && !hasAnswer + // A settled no-answer turn whose trace recorded an error → render the bubble itself as a + // failure (red), with the message inline — not a nested alert box. + const isError = noResponse && !!traceError + + // Only the message being generated shows the loading state, and only until it has content. + if (!isUser && isStreaming && !hasContent) { + return ( + + ) + } + + const defaultBody = ( +
+ {message.parts.map((part, i) => { + if (part.type === "text") { + const text = (part as {text: string}).text + if (!text) return null + // Render markdown for both roles so typed markdown displays properly. + return + } + if (part.type === "reasoning") { + const reasoning = part as ReasoningUIPart + if (!reasoning.text) return null + return ( + + ) + } + if (isToolPart(part.type)) { + return ( + + ) + } + // Multi-modality: render attachments (sent by the user or returned by the + // agent) as X `FileCard`s — images preview inline, other kinds show a typed + // file chip with a download link. + if (part.type === "file") { + const file = part as FileUIPart + const kind = fileKind(file.mediaType) + return ( + + {file.mediaType} + + ) : undefined + } + /> + ) + } + return null + })} + + {sources.length > 0 && ( +
+ + Sources + + {sources.map((s, i) => ( + + {s.title || s.url} + + ))} +
+ )} + + {noResponse && ( + + No response — the agent ended its turn without answering. + + )} +
+ ) + + // Failed run: the whole bubble reads as the error (red), message inline — no nested box. + const errorBody = ( +
+ +
+ The agent run failed + {traceError} +
+
+ ) + + const body = isError ? errorBody : defaultBody + + // Control toolbar — an X `Actions` row that FLOATS over the bubble's bottom edge. It is + // absolutely positioned (out of flow), so it adds no height: bubbles sit tight with no + // reserved lane, and revealing it only fades opacity — no layout shift either way. + // `pointer-events-none` while hidden keeps the invisible buttons unclickable. `Actions` + // items carry no `disabled`, so the busy guard lives in the handlers: `onRewind` → + // `handleRewind` early-returns while a stream is in flight (copy / view-trace are always + // safe). The item `label` renders as the hover tooltip. + const toolbarReveal = + "opacity-0 transition-opacity duration-150 pointer-events-none " + + "group-hover:opacity-100 group-hover:pointer-events-auto " + + "focus-within:opacity-100 focus-within:pointer-events-auto" + const rewindAction: ActionsProps["items"][number] = { + key: "rewind", + label: isUser + ? "Rewind here — edit and re-run the conversation from this message" + : "Rewind here — re-run this turn", + icon: , + onItemClick: () => onRewind(), + } + + const toolbar = isUser ? ( + + ) : ( + <> + {traceId && } + , + onItemClick: () => navigator.clipboard.writeText(fullText), + }, + rewindAction, + ...(traceId + ? [ + { + key: "trace", + label: "View trace", + icon: , + onItemClick: () => openTraceDrawer({traceId}), + }, + ] + : []), + ]} + /> + + ) + + // `group relative` → the floating toolbar reveals on hover/focus of the whole message row + // and anchors to the bubble without consuming layout space. The row is a flex that + // justifies the (width-capped) bubble to its side, so the opposite side keeps whitespace — + // agent bubbles hug the left, user bubbles the right, neither spans the full column. + return ( +
+ + placement={isUser ? "end" : "start"} + variant={isUser ? "filled" : "outlined"} + avatar={avatarFor(isUser)} + className="min-w-0 max-w-[85%]" + classNames={{ + content: `min-w-0 max-w-full overflow-hidden ${ + isError ? "!border-colorErrorBorder !bg-[var(--ant-color-error-bg)]" : "" + }`, + body: "min-w-0 max-w-full overflow-hidden", + }} + content={body} + /> +
+ {toolbar} +
+
+ ) +} + +export default memo(AgentMessage) diff --git a/web/oss/src/components/AgentChatSlice/components/SessionHistoryMenu.tsx b/web/oss/src/components/AgentChatSlice/components/SessionHistoryMenu.tsx new file mode 100644 index 0000000000..d241a2d94f --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/SessionHistoryMenu.tsx @@ -0,0 +1,138 @@ +import {useState} from "react" + +import {ClockCounterClockwise, Trash} from "@phosphor-icons/react" +import {Button, Empty, Popover, Tag, Tooltip, Typography} from "antd" +import {useAtomValue, useSetAtom} from "jotai" + +import { + deleteSessionAtom, + firstUserText, + openSessionAtom, + openSessionIdsAtom, + sessionHistoryAtom, + sessionMessagesAtom, +} from "../state/sessions" + +const {Text} = Typography + +/** Compact "2m / 3h / 5d ago" stamp; falls back to empty for pre-upgrade sessions. */ +const timeAgo = (ts?: number): string => { + if (!ts) return "" + const s = Math.max(0, Math.round((Date.now() - ts) / 1000)) + if (s < 60) return "just now" + const m = Math.round(s / 60) + if (m < 60) return `${m}m ago` + const h = Math.round(m / 60) + if (h < 24) return `${h}h ago` + return `${Math.round(h / 24)}d ago` +} + +/** + * The scrollable history list. Rendered as Popover content (so it only mounts — and only + * subscribes to `sessionMessagesAtom` for its labels — while the popover is open). Clicking a + * row reopens that session as a tab (or focuses it if already open); the trash icon deletes it + * permanently (tab + history + messages). + */ +const SessionHistoryList = ({onPicked}: {onPicked: () => void}) => { + const history = useAtomValue(sessionHistoryAtom) + const openIds = useAtomValue(openSessionIdsAtom) + const allMessages = useAtomValue(sessionMessagesAtom) + const openSession = useSetAtom(openSessionAtom) + const deleteSession = useSetAtom(deleteSessionAtom) + + if (history.length === 0) { + return ( + No sessions yet} + className="!my-2" + /> + ) + } + + return ( +
+ {history.map((session) => { + const label = + session.title || firstUserText(allMessages[session.id]) || "Untitled chat" + const isOpen = openIds.has(session.id) + return ( +
{ + openSession(session.id) + onPicked() + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + openSession(session.id) + onPicked() + } + }} + className="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-colorFillTertiary" + > +
+ + {label} + + + {timeAgo(session.createdAt)} + +
+ {isOpen && ( + + open + + )} + +
+ ) + })} +
+ ) +} + +/** + * History picker for the agent-chat tab bar: a clock button that opens the list of all past + * sessions for the current app (open + closed) so closed conversations can be reopened. Lives + * in the Tabs' `tabBarExtraContent` so it sits beside the `+` add control. + */ +const SessionHistoryMenu = () => { + const [open, setOpen] = useState(false) + return ( + Session history} + content={ setOpen(false)} />} + > + + + + {expanded && ( +
+ {part.input !== undefined && ( +
+ + Input + + +
+ )} + + {part.state === "output-available" && ( +
+ + Output + + +
+ )} + + {part.state === "output-error" && ( +
+ + Error + + +
+ )} + + {state === "output-denied" && ( + + You denied this action; it was not executed. + + )} + + {state === "approval-requested" && approval?.id && ( +
+ Run this tool? + + +
+ )} +
+ )} + + ) +} + +export default memo(ToolPart) diff --git a/web/oss/src/components/AgentChatSlice/index.tsx b/web/oss/src/components/AgentChatSlice/index.tsx new file mode 100644 index 0000000000..dbd23e806a --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/index.tsx @@ -0,0 +1,182 @@ +import {useEffect, useRef, useState} from "react" + +import {Segmented, Tabs, Tooltip, Typography} from "antd" +import {useAtomValue, useSetAtom, useStore} from "jotai" +import {useRouter} from "next/router" + +import {routerAppIdAtom} from "@/oss/state/app/atoms/fetcher" + +import {type AgentChatTrack, DEFAULT_TRACK} from "./assets/constants" +import {loadSessionMessages} from "./assets/loadSession" +import AgentChatConversation from "./components/AgentChatConversation" +import SessionHistoryMenu from "./components/SessionHistoryMenu" +import SessionTabLabel from "./components/SessionTabLabel" +import { + activeSessionIdAtom, + addSessionAtom, + adoptSessionAtom, + closeSessionAtom, + renameSessionAtom, + sessionLabel, + sessionMessagesAtom, + sessionsListAtom, + setActiveSessionAtom, +} from "./state/sessions" + +const {Text, Title} = Typography + +/** + * Agent chat streaming slice — contract v1. + * + * A real `useChat` conversation streaming the v6 UI Message Stream protocol from the RAG_QA + * contract service. Proves the FE↔service streaming contract end to end: text + tool-call + * lifecycle + one human approval + a trace link into the existing trace drawer. + * + * Multiple parallel conversations are exposed as top-level dynamic tabs (one `useChat` + * session each; add with `+`, close with `×`, double-click a tab to rename). The session + * list, active tab, and each conversation's messages persist to localStorage, so the tabs + * survive a reload. antd keeps visited panes mounted, so switching tabs preserves a + * session's live stream / approval state. Does NOT touch the Jotai/web-worker playground + * pipeline — `useChat` owns these conversations. + * + * The Track A/B request-contract toggle (an internal experiment comparing how the request + * body is shaped) is demoted to a dev-only control in the tab bar's extra slot; the response + * stream + rendering are identical across tracks. + */ +const AgentChatSlice = () => { + const [track, setTrack] = useState(DEFAULT_TRACK) + const appId = useAtomValue(routerAppIdAtom) + const store = useStore() + const router = useRouter() + + const sessions = useAtomValue(sessionsListAtom) + const rawActiveId = useAtomValue(activeSessionIdAtom) + const allMessages = useAtomValue(sessionMessagesAtom) + const addSession = useSetAtom(addSessionAtom) + const adoptSession = useSetAtom(adoptSessionAtom) + const closeSession = useSetAtom(closeSessionAtom) + const renameSession = useSetAtom(renameSessionAtom) + const setActiveSession = useSetAtom(setActiveSessionAtom) + + // Open-from-observability: a `?session=` deep link (from a trace / session drawer) + // opens that session as a tab. Hydrate its messages first — from localStorage if this + // browser ran it, else from the server seam (`loadSessionMessages`, inert until a backend + // SessionStore exists) — THEN adopt, so the conversation seeds with whatever we found. The + // param is stripped afterwards so a reload / tab switch doesn't re-adopt. + const sessionParam = router.query.session + useEffect(() => { + if (!router.isReady) return + const id = Array.isArray(sessionParam) ? sessionParam[0] : sessionParam + if (!id) return + let cancelled = false + const open = () => { + if (!cancelled) adoptSession({id}) + } + const existing = store.get(sessionMessagesAtom)[id] + if (existing && existing.length) { + open() + } else { + loadSessionMessages(id).then((msgs) => { + if (cancelled) return + if (msgs && msgs.length) { + store.set(sessionMessagesAtom, { + ...store.get(sessionMessagesAtom), + [id]: msgs, + }) + } + open() + }) + } + const rest = {...router.query} + delete rest.session + router.replace({pathname: router.pathname, query: rest}, undefined, {shallow: true}) + return () => { + cancelled = true + } + }, [router.isReady, sessionParam]) + + // Always keep at least one tab. Re-arms when the list drains (e.g. switching to an app + // with no sessions yet) without double-firing under StrictMode. + const seeded = useRef(false) + useEffect(() => { + if (sessions.length === 0 && !seeded.current) { + seeded.current = true + addSession() + } + if (sessions.length > 0) seeded.current = false + }, [sessions.length, addSession]) + + // Tolerate a stale active id (its tab was closed) by falling back to the first tab. + const activeId = sessions.some((s) => s.id === rawActiveId) ? rawActiveId : sessions[0]?.id + + return ( +
+
+ + Agent chat (contract v1) + + + Parallel agent conversations — add a tab for each. + +
+ + { + if (action === "add") addSession() + else if (typeof targetKey === "string") closeSession(targetKey) + }} + tabBarExtraContent={{ + right: ( +
+ + + + size="small" + value={track} + onChange={setTrack} + options={[ + {label: "A", value: "uimessage"}, + {label: "B", value: "agenta"}, + ]} + /> + +
+ ), + }} + items={sessions.map((session, index) => ({ + key: session.id, + closable: sessions.length > 1, + label: ( + renameSession({id: session.id, title})} + /> + ), + children: ( + + ), + }))} + /> +
+ ) +} + +export default AgentChatSlice diff --git a/web/oss/src/components/AgentChatSlice/state/sessions.ts b/web/oss/src/components/AgentChatSlice/state/sessions.ts new file mode 100644 index 0000000000..ee7a828454 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/state/sessions.ts @@ -0,0 +1,258 @@ +import type {UIMessage} from "ai" +import {atom, type Getter} from "jotai" +import {atomFamily, atomWithStorage, selectAtom} from "jotai/utils" + +import {routerAppIdAtom} from "@/oss/state/app/atoms/fetcher" + +/** + * Multi-session model for the agent chat slice. The playground hosts several parallel agent + * conversations as top-level dynamic tabs (no side rail); this holds the session history, which + * tabs are open, the active tab, and each session's persisted messages. + * + * Two distinct concerns, both app-scoped (the playground is app-scoped, like + * `selectedVariantsByAppAtom`): + * - HISTORY (`sessionsByAppAtom`): every session ever created for the app. A closed tab stays + * here so it can be reopened from the history picker; only an explicit delete removes it. + * - OPEN TABS (`openIdsByAppAtom`): which history sessions are currently shown as tabs, in tab + * order. Closing a tab drops its id here but keeps the session (and its messages). + * Messages are keyed by the globally-unique session id, so they need no app dimension. + * + * Persistence: everything is `atomWithStorage`, so history, tabs, and conversations survive a + * reload. NOTE: attachments are stored inline as `data:` URLs (see `assets/files.ts`); a + * conversation with large files can approach the localStorage quota — acceptable for v1. + */ + +export interface AgentChatSession { + id: string + /** User-set title. When empty, the UI falls back to the first user message / "Chat N". */ + title?: string + /** Creation time (ms epoch). Orders the history picker; absent on pre-upgrade sessions. */ + createdAt?: number +} + +const GLOBAL_APP_KEY = "__global__" + +const appKeyAtom = atom((get) => get(routerAppIdAtom) || GLOBAL_APP_KEY) + +// One source of truth per concern, keyed by app id. Scoped accessors below derive the +// current app's slice (mirrors the playground's `selectedVariantsByAppAtom` pattern). +// +// `getOnInit: true` — read localStorage synchronously on init. Without it the atom starts as +// the empty default `{}` on every mount and only hydrates afterwards, so the "seed one tab" +// effect sees an empty list in that window and creates a stray session on every reload/HMR. +const STORAGE_OPTS = {getOnInit: true} as const + +/** Full per-app session history (open AND closed). */ +const sessionsByAppAtom = atomWithStorage>( + "agenta:agent-chat:sessions", + {}, + undefined, + STORAGE_OPTS, +) + +/** + * Which sessions are open as tabs, per app, in tab order. + * + * Migration: before this atom is ever written for an app, the open set defaults to the whole + * history — every pre-upgrade session was an open tab (see `currentOpenIds`). Once any tab op + * writes an explicit list, that list is authoritative. + */ +const openIdsByAppAtom = atomWithStorage>( + "agenta:agent-chat:open-sessions", + {}, + undefined, + STORAGE_OPTS, +) + +const activeByAppAtom = atomWithStorage>( + "agenta:agent-chat:active-session", + {}, + undefined, + STORAGE_OPTS, +) + +/** Persisted messages per session id. Written when a conversation's stream settles. */ +export const sessionMessagesAtom = atomWithStorage>( + "agenta:agent-chat:messages", + {}, + undefined, + STORAGE_OPTS, +) + +/** Open tab ids for an app, with the pre-upgrade fallback (everything open). Pure read helper + * for the writers below — never mutates. */ +const currentOpenIds = (get: Getter, key: string): string[] => { + const explicit = get(openIdsByAppAtom)[key] + if (explicit) return explicit + return (get(sessionsByAppAtom)[key] ?? []).map((s) => s.id) +} + +/** All sessions for the current app (history), newest first. Backs the history picker. */ +export const sessionHistoryAtom = atom((get) => { + const list = get(sessionsByAppAtom)[get(appKeyAtom)] ?? [] + // Newest first; pre-upgrade sessions (no createdAt) sort last, preserving their order. + return [...list].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)) +}) + +/** Open tab ids for the current app, in tab order (with the migration fallback). */ +const openIdsAtom = atom((get) => currentOpenIds(get, get(appKeyAtom))) + +/** Sessions shown as tabs, in tab order. */ +export const sessionsListAtom = atom((get) => { + const byId = new Map( + (get(sessionsByAppAtom)[get(appKeyAtom)] ?? []).map((s) => [s.id, s] as const), + ) + return get(openIdsAtom) + .map((id) => byId.get(id)) + .filter((s): s is AgentChatSession => Boolean(s)) +}) + +/** Active session id for the current app (may be stale if that tab was closed — the UI + * falls back to the first open tab when this id isn't in the open list). */ +export const activeSessionIdAtom = atom((get) => get(activeByAppAtom)[get(appKeyAtom)] ?? "") + +/** Set of currently-open session ids (used to label the history picker). */ +export const openSessionIdsAtom = atom((get) => new Set(get(openIdsAtom))) + +/** Create a session and make it the active open tab. Returns the new id. */ +export const addSessionAtom = atom(null, (get, set) => { + const key = get(appKeyAtom) + const id = crypto.randomUUID() + // Read open ids BEFORE mutating history, else the fallback would re-count the new id. + const open = currentOpenIds(get, key) + const all = get(sessionsByAppAtom) + set(sessionsByAppAtom, {...all, [key]: [...(all[key] ?? []), {id, createdAt: Date.now()}]}) + set(openIdsByAppAtom, {...get(openIdsByAppAtom), [key]: [...open, id]}) + set(activeByAppAtom, {...get(activeByAppAtom), [key]: id}) + return id +}) + +/** Close a tab: drop it from the open list (KEEP the session + messages so it can be reopened + * from the history picker) and re-point the active tab to a neighbour if it was the one closed. */ +export const closeSessionAtom = atom(null, (get, set, id: string) => { + const key = get(appKeyAtom) + const open = currentOpenIds(get, key) + const nextOpen = open.filter((x) => x !== id) + set(openIdsByAppAtom, {...get(openIdsByAppAtom), [key]: nextOpen}) + + const active = get(activeByAppAtom) + if (active[key] === id) { + const closedIdx = open.indexOf(id) + const neighbour = nextOpen[Math.min(closedIdx, nextOpen.length - 1)] ?? "" + set(activeByAppAtom, {...active, [key]: neighbour}) + } +}) + +/** Reopen a session as a tab (or just focus it if already open) and make it active. */ +export const openSessionAtom = atom(null, (get, set, id: string) => { + const key = get(appKeyAtom) + const open = currentOpenIds(get, key) + if (!open.includes(id)) { + set(openIdsByAppAtom, {...get(openIdsByAppAtom), [key]: [...open, id]}) + } + set(activeByAppAtom, {...get(activeByAppAtom), [key]: id}) +}) + +/** + * Ensure a session with `id` exists in history, is open, and is active — used when opening a + * session from a deep link / observability trace. Creates the history entry if it's unknown to + * this browser (its messages come from `sessionMessagesAtom`, hydrated locally or server-side). + */ +export const adoptSessionAtom = atom( + null, + (get, set, {id, title}: {id: string; title?: string}) => { + const key = get(appKeyAtom) + const all = get(sessionsByAppAtom) + const list = all[key] ?? [] + if (!list.some((s) => s.id === id)) { + set(sessionsByAppAtom, {...all, [key]: [...list, {id, title, createdAt: Date.now()}]}) + } + const open = currentOpenIds(get, key) + if (!open.includes(id)) { + set(openIdsByAppAtom, {...get(openIdsByAppAtom), [key]: [...open, id]}) + } + set(activeByAppAtom, {...get(activeByAppAtom), [key]: id}) + }, +) + +/** Permanently delete a session: drop it from history, the open tabs, and its messages. */ +export const deleteSessionAtom = atom(null, (get, set, id: string) => { + const key = get(appKeyAtom) + const all = get(sessionsByAppAtom) + set(sessionsByAppAtom, {...all, [key]: (all[key] ?? []).filter((s) => s.id !== id)}) + + const open = currentOpenIds(get, key) + if (open.includes(id)) { + set(openIdsByAppAtom, {...get(openIdsByAppAtom), [key]: open.filter((x) => x !== id)}) + } + + const active = get(activeByAppAtom) + if (active[key] === id) { + set(activeByAppAtom, {...active, [key]: open.filter((x) => x !== id)[0] ?? ""}) + } + + const messages = {...get(sessionMessagesAtom)} + if (id in messages) { + delete messages[id] + set(sessionMessagesAtom, messages) + } +}) + +export const renameSessionAtom = atom( + null, + (get, set, {id, title}: {id: string; title: string}) => { + const key = get(appKeyAtom) + const all = get(sessionsByAppAtom) + const list = (all[key] ?? []).map((s) => + s.id === id ? {...s, title: title.trim() || undefined} : s, + ) + set(sessionsByAppAtom, {...all, [key]: list}) + }, +) + +export const setActiveSessionAtom = atom(null, (get, set, id: string) => { + const key = get(appKeyAtom) + set(activeByAppAtom, {...get(activeByAppAtom), [key]: id}) +}) + +/** Write a session's messages to the persisted store (called when its stream settles). */ +export const persistSessionMessagesAtom = atom( + null, + (get, set, {id, messages}: {id: string; messages: UIMessage[]}) => { + set(sessionMessagesAtom, {...get(sessionMessagesAtom), [id]: messages}) + }, +) + +/** First user message text, used as the tab/history label when the session is untitled. */ +export const firstUserText = (messages: UIMessage[] | undefined): string => { + const first = messages?.find((m) => m.role === "user") + if (!first) return "" + return first.parts + .filter((p) => p.type === "text") + .map((p) => (p as {text: string}).text) + .join(" ") + .trim() +} + +/** Tab label: explicit title → first user message (truncated) → positional "Chat N". */ +export const sessionLabel = ( + session: AgentChatSession, + messages: UIMessage[] | undefined, + index: number, +): string => { + if (session.title) return session.title + const text = firstUserText(messages) + if (text) return text.length > 24 ? `${text.slice(0, 24)}…` : text + return `Chat ${index + 1}` +} + +/** + * Per-session first-user-text, as a focused selector. Subscribers re-render only when this + * STRING changes (stable once the first message is sent) — not on every streamed token — so a + * tab label doesn't churn while its conversation streams. Used instead of subscribing the tab + * bar to the whole `sessionMessagesAtom` (which changes on every message and would re-render + * the bar + all mounted panes mid-stream). + */ +export const sessionFirstUserTextAtomFamily = atomFamily((id: string) => + selectAtom(sessionMessagesAtom, (all) => firstUserText(all[id])), +) diff --git a/web/oss/src/components/Layout/Layout.tsx b/web/oss/src/components/Layout/Layout.tsx index 173a9a6144..75b135f064 100644 --- a/web/oss/src/components/Layout/Layout.tsx +++ b/web/oss/src/components/Layout/Layout.tsx @@ -321,9 +321,13 @@ const AppWithVariants = memo( ) : ( import("@/oss/components/AgentChatSlice/AgentChatPanel"), { + ssr: false, +}) + /** * Sync state tag slot — renders the sync state badge in each row header. * Shown only when connected to an API-backed testset. @@ -73,6 +80,9 @@ const Playground: FC = () => { ChatTurnAssistantActions: (props) => ( ), + // Third generation arm: agent-type entities render the agent-chat surface. + // Lazy — pulls in the AI SDK only when an agent workflow is open. + AgentGenerationPanel: AgentChatPanel, renderSyncStateTag: PlaygroundSyncStateTag, } as unknown as PlaygroundUIProviders diff --git a/web/oss/src/components/SharedDrawers/SessionDrawer/assets/utils.ts b/web/oss/src/components/SharedDrawers/SessionDrawer/assets/utils.ts index d55a2f3747..80da211e1f 100644 --- a/web/oss/src/components/SharedDrawers/SessionDrawer/assets/utils.ts +++ b/web/oss/src/components/SharedDrawers/SessionDrawer/assets/utils.ts @@ -1,6 +1,31 @@ +const GENERATOR_REPR = /^<(?:async_)?generator object/ + +/** + * A streamed agent run's ROOT span returns a generator, so its `outputs` is the generator + * object's repr (``), not the reply — the span is closed + * before the stream produces text. The real assistant output lives on the nested `agent`-type + * span (`invoke_agent`). Prefer that; fall back to the root's unless it's the generator repr. + */ +const agentRunOutputs = (trace: any): any => { + let found: any + const visit = (node: any) => { + if (found !== undefined || !node) return + if (node.span_type === "agent") { + const out = node.outputs ?? node.attributes?.ag?.data?.outputs + if (out !== undefined) found = out + } + ;(node.children ?? []).forEach(visit) + } + visit(trace) + if (found !== undefined) return found + + const rootOut = trace.outputs || trace.attributes?.ag?.data?.outputs + return typeof rootOut === "string" && GENERATOR_REPR.test(rootOut) ? undefined : rootOut +} + export const extractTraceData = (trace: any) => { const inputs = trace.inputs || trace.attributes?.ag?.data?.inputs - const outputs = trace.outputs || trace.attributes?.ag?.data?.outputs + const outputs = agentRunOutputs(trace) const messages: {role: string; content: string}[] = [] @@ -21,7 +46,10 @@ export const extractTraceData = (trace: any) => { // Handle Outputs if (outputs) { - if (Array.isArray(outputs.completion)) { + if (typeof outputs === "string") { + // The agent span's output is the assistant's reply text — render it directly. + messages.push({role: "assistant", content: outputs}) + } else if (Array.isArray(outputs.completion)) { messages.push( ...outputs.completion.map((m: any) => ({role: m.role, content: m.content})), ) diff --git a/web/oss/src/components/SharedDrawers/SessionDrawer/components/SessionHeader/index.tsx b/web/oss/src/components/SharedDrawers/SessionDrawer/components/SessionHeader/index.tsx index b658f8ee1c..7deb1feb9f 100644 --- a/web/oss/src/components/SharedDrawers/SessionDrawer/components/SessionHeader/index.tsx +++ b/web/oss/src/components/SharedDrawers/SessionDrawer/components/SessionHeader/index.tsx @@ -1,21 +1,39 @@ import {useCallback, useMemo} from "react" import {CopyTooltip as TooltipWithCopyAction} from "@agenta/ui/copy-tooltip" -import {CaretDown, CaretUp, SidebarSimple} from "@phosphor-icons/react" +import {CaretDown, CaretUp, ChatCenteredDots, SidebarSimple} from "@phosphor-icons/react" import {Button, Tag, Typography} from "antd" import {useAtom, useAtomValue, useSetAtom} from "jotai" +import {isAgentChatSliceEnabled} from "@/oss/components/AgentChatSlice/assets/constants" +import {useAppNavigation} from "@/oss/state/appState" import {filteredSessionIdsAtom} from "@/oss/state/newObservability" +import {urlAtom} from "@/oss/state/url" import {openSessionDrawerWithUrlAtom} from "@/oss/state/url/session" import useSessionDrawer from "../../hooks/useSessionDrawer" -import {isAnnotationVisibleAtom} from "../../store/sessionDrawerStore" +import {closeSessionDrawerAtom, isAnnotationVisibleAtom} from "../../store/sessionDrawerStore" const SessionHeader = () => { const {sessionId} = useSessionDrawer() const [isAnnotationVisible, setIsAnnotationVisible] = useAtom(isAnnotationVisibleAtom) const sessionIds = useAtomValue(filteredSessionIdsAtom) const openSessionDrawer = useSetAtom(openSessionDrawerWithUrlAtom) + const url = useAtomValue(urlAtom) + const navigation = useAppNavigation() + const closeSessionDrawer = useSetAtom(closeSessionDrawerAtom) + + // Open this session in the agent-chat surface. Gated on the slice being enabled and an app + // context (the agent-chat route is app-scoped); the page reconstructs the conversation from + // localStorage (or the server seam) via the `?session=` param. + const canOpenInAgentChat = isAgentChatSliceEnabled() && Boolean(url.appId) && Boolean(sessionId) + const handleOpenInAgentChat = useCallback(() => { + if (!canOpenInAgentChat) return + navigation.push( + `${url.baseAppURL}/${url.appId}/agent-chat?session=${encodeURIComponent(sessionId || "")}`, + ) + closeSessionDrawer() + }, [canOpenInAgentChat, navigation, url.baseAppURL, url.appId, sessionId, closeSessionDrawer]) const currentIndex = useMemo(() => { if (!sessionId || !sessionIds) return -1 @@ -63,14 +81,25 @@ const SessionHeader = () => { - +
+ {canOpenInAgentChat && ( + + )} + +
) } diff --git a/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceContent/components/TraceTypeHeader/index.tsx b/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceContent/components/TraceTypeHeader/index.tsx index 96497607f1..5d1ba2806e 100644 --- a/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceContent/components/TraceTypeHeader/index.tsx +++ b/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceContent/components/TraceTypeHeader/index.tsx @@ -15,6 +15,7 @@ import AddToTestsetButton from "@/oss/components/SharedDrawers/AddToTestsetDrawe import AnnotateDrawerButton from "@/oss/components/SharedDrawers/AnnotateDrawer/assets/AnnotateDrawerButton" import {openTraceInPlaygroundAtom} from "@/oss/components/SharedDrawers/TraceDrawer/store/openInPlayground" import {closeTraceDrawerAtom} from "@/oss/components/SharedDrawers/TraceDrawer/store/traceDrawerStore" +import type {TraceSpanNode} from "@/oss/services/tracing/types" import {useAppNavigation} from "@/oss/state/appState" import {urlAtom} from "@/oss/state/url" import {buildPlaygroundUrl} from "@/oss/state/url/playground" @@ -41,6 +42,10 @@ const DeleteTraceModal = dynamic(() => import("../../../DeleteTraceModal"), { */ const INVOCATION_SPAN_TYPES = new Set(["workflow", "task", "agent", "chain"]) +/** True when a span tree contains an `agent`-type span (i.e. it's an agent run). */ +const hasAgentSpan = (node: TraceSpanNode): boolean => + node.span_type === "agent" || (node.children ?? []).some((c) => hasAgentSpan(c)) + const TraceTypeHeader = ({ activeTrace, error, @@ -131,7 +136,16 @@ const TraceTypeHeader = ({ if (!activeTrace) return setIsOpening(true) try { - const result = await setOpenInPlayground(activeTrace) + // For an agent run, only the root invocation span carries the chat shape (messages + // + agent config + session_id). Child spans (`invoke_agent`, `turn`, …) carry a bare + // `{prompt}` and would open as a non-agent completion. So replay the whole agent run + // from its root regardless of which span was clicked. + const traceRoot = traces?.find((t) => t.trace_id === activeTrace.trace_id) + const spanToOpen = + traceRoot && traceRoot.span_id !== activeTrace.span_id && hasAgentSpan(traceRoot) + ? traceRoot + : activeTrace + const result = await setOpenInPlayground(spanToOpen) // Need at least an entityId (revision or ephemeral) to open. if (!result || !result.entityId) return @@ -181,6 +195,7 @@ const TraceTypeHeader = ({ } }, [ activeTrace, + traces, setOpenInPlayground, url.baseAppURL, navigation, diff --git a/web/oss/src/components/pages/app-management/components/CreateAppDropdown/index.tsx b/web/oss/src/components/pages/app-management/components/CreateAppDropdown/index.tsx index fdc6300665..93a0d0f6e0 100644 --- a/web/oss/src/components/pages/app-management/components/CreateAppDropdown/index.tsx +++ b/web/oss/src/components/pages/app-management/components/CreateAppDropdown/index.tsx @@ -34,6 +34,12 @@ const ITEMS: CreateAppDropdownItem[] = [ description: "Single-shot prompt completion.", testId: "create-app-dropdown-completion", }, + { + type: "agent", + label: "Agent", + description: "Agent that uses tools over multiple turns.", + testId: "create-app-dropdown-agent", + }, ] interface CreateAppDropdownProps { diff --git a/web/oss/src/components/pages/app-management/modals/CreateAppTypeModal/index.tsx b/web/oss/src/components/pages/app-management/modals/CreateAppTypeModal/index.tsx index 9f07b6b354..beeda459ff 100644 --- a/web/oss/src/components/pages/app-management/modals/CreateAppTypeModal/index.tsx +++ b/web/oss/src/components/pages/app-management/modals/CreateAppTypeModal/index.tsx @@ -51,6 +51,12 @@ const OPTIONS: CreateAppTypeOption[] = [ description: "Single-shot prompt completion.", testId: "create-app-type-modal-completion", }, + { + type: "agent", + label: "Agent", + description: "Agent that uses tools over multiple turns.", + testId: "create-app-type-modal-agent", + }, ] interface CreateAppTypeModalProps { diff --git a/web/oss/src/components/pages/observability/components/SessionsTable/assets/sessionCellStore.tsx b/web/oss/src/components/pages/observability/components/SessionsTable/assets/sessionCellStore.tsx new file mode 100644 index 0000000000..8f8533e627 --- /dev/null +++ b/web/oss/src/components/pages/observability/components/SessionsTable/assets/sessionCellStore.tsx @@ -0,0 +1,32 @@ +import {createContext, useContext} from "react" + +import {getDefaultStore, useAtomValue} from "jotai" +import type {Atom} from "jotai" + +type JotaiStore = ReturnType + +/** + * The sessions table renders its rows inside `InfiniteVirtualTable`'s ISOLATED Jotai store (it + * creates one whenever no `store` prop is passed, and wraps rows + cells in a `` for + * it). The per-session cell atoms (`sessionTraceCountAtomFamily`, `sessionFirstInputAtomFamily`, + * …) transitively depend on app context — `projectIdAtom`, `selectedAppIdAtom`, the per-session + * spans query — which only lives in the PAGE's store. Read inside the isolated store those deps + * resolve to their empty defaults, so every cell renders blank even though the data is loaded. + * + * This carries the page store down to the cells via a plain React context (independent of the + * table's Jotai Provider), so cells read the store that actually has the data + context. We do + * NOT pass the store to the table itself — that flips it off its isolated-store code path, which + * blanks row rendering in this version of the table package. + */ +const SessionStoreContext = createContext(null) + +export const SessionStoreProvider = SessionStoreContext.Provider + +/** The page store the sessions table was rendered in (falls back to the default store). */ +export const useSessionStore = (): JotaiStore => + useContext(SessionStoreContext) ?? getDefaultStore() + +/** `useAtomValue`, but always reading the page store (see `SessionStoreContext`). Mirrors + * jotai's return type (`Awaited`) so call sites are identical to plain `useAtomValue`. */ +export const useSessionAtomValue = (atom: Atom): Awaited => + useAtomValue(atom, {store: useSessionStore()}) diff --git a/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/DurationCell.tsx b/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/DurationCell.tsx index 10577902b4..aad9f8d9b7 100644 --- a/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/DurationCell.tsx +++ b/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/DurationCell.tsx @@ -1,5 +1,4 @@ import {Skeleton} from "antd" -import {useAtomValue} from "jotai" import { sessionDurationAtomFamily, @@ -7,10 +6,11 @@ import { } from "@/oss/state/newObservability/atoms/queries" import DurationCellDisplay from "../../../DurationCell" // Reusing presentation +import {useSessionAtomValue} from "../../assets/sessionCellStore" export const DurationCell = ({sessionId}: {sessionId: string}) => { - const isLoading = useAtomValue(sessionsLoadingAtom) - const duration = useAtomValue(sessionDurationAtomFamily(sessionId)) + const isLoading = useSessionAtomValue(sessionsLoadingAtom) + const duration = useSessionAtomValue(sessionDurationAtomFamily(sessionId)) if (isLoading) return diff --git a/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/EndTimeCell.tsx b/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/EndTimeCell.tsx index a1e740fb0f..9de5e2b848 100644 --- a/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/EndTimeCell.tsx +++ b/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/EndTimeCell.tsx @@ -1,5 +1,4 @@ import {Skeleton} from "antd" -import {useAtomValue} from "jotai" import { sessionTimeRangeAtomFamily, @@ -7,10 +6,11 @@ import { } from "@/oss/state/newObservability/atoms/queries" import TimestampCell from "../../../TimestampCell" +import {useSessionAtomValue} from "../../assets/sessionCellStore" export const EndTimeCell = ({sessionId}: {sessionId: string}) => { - const isLoading = useAtomValue(sessionsLoadingAtom) - const {endTime} = useAtomValue(sessionTimeRangeAtomFamily(sessionId)) + const isLoading = useSessionAtomValue(sessionsLoadingAtom) + const {endTime} = useSessionAtomValue(sessionTimeRangeAtomFamily(sessionId)) if (isLoading) return if (!endTime) return <>- diff --git a/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/FirstInputCell.tsx b/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/FirstInputCell.tsx index 578100019e..31701f92c5 100644 --- a/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/FirstInputCell.tsx +++ b/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/FirstInputCell.tsx @@ -1,6 +1,5 @@ import {LastInputMessageCell} from "@agenta/ui/cell-renderers" import {Skeleton} from "antd" -import {useAtomValue} from "jotai" import {sanitizeDataWithBlobUrls} from "@/oss/lib/helpers/utils" import { @@ -8,9 +7,11 @@ import { sessionsLoadingAtom, } from "@/oss/state/newObservability/atoms/queries" +import {useSessionAtomValue} from "../../assets/sessionCellStore" + export const FirstInputCell = ({sessionId}: {sessionId: string}) => { - const isLoading = useAtomValue(sessionsLoadingAtom) - const firstInput = useAtomValue(sessionFirstInputAtomFamily(sessionId)) + const isLoading = useSessionAtomValue(sessionsLoadingAtom) + const firstInput = useSessionAtomValue(sessionFirstInputAtomFamily(sessionId)) if (isLoading) return if (firstInput === undefined) return "" diff --git a/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/LastOutputCell.tsx b/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/LastOutputCell.tsx index d6b2f4463e..62168bad51 100644 --- a/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/LastOutputCell.tsx +++ b/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/LastOutputCell.tsx @@ -1,6 +1,5 @@ import {SmartCellContent} from "@agenta/ui/cell-renderers" import {Skeleton} from "antd" -import {useAtomValue} from "jotai" import {sanitizeDataWithBlobUrls} from "@/oss/lib/helpers/utils" import { @@ -8,9 +7,11 @@ import { sessionsLoadingAtom, } from "@/oss/state/newObservability/atoms/queries" +import {useSessionAtomValue} from "../../assets/sessionCellStore" + export const LastOutputCell = ({sessionId}: {sessionId: string}) => { - const isLoading = useAtomValue(sessionsLoadingAtom) - const lastOutput = useAtomValue(sessionLastOutputAtomFamily(sessionId)) + const isLoading = useSessionAtomValue(sessionsLoadingAtom) + const lastOutput = useSessionAtomValue(sessionLastOutputAtomFamily(sessionId)) if (isLoading) return if (lastOutput === undefined) return "" diff --git a/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/SessionIdCell.tsx b/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/SessionIdCell.tsx index 0fcd143913..1235ef44f6 100644 --- a/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/SessionIdCell.tsx +++ b/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/SessionIdCell.tsx @@ -4,7 +4,10 @@ import {Tag} from "antd" export const SessionIdCell = ({sessionId}: {sessionId: string}) => { return ( - + # {sessionId} diff --git a/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/StartTimeCell.tsx b/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/StartTimeCell.tsx index 9573b748f6..291bb3bc94 100644 --- a/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/StartTimeCell.tsx +++ b/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/StartTimeCell.tsx @@ -1,5 +1,4 @@ import {Skeleton} from "antd" -import {useAtomValue} from "jotai" import { sessionTimeRangeAtomFamily, @@ -7,10 +6,11 @@ import { } from "@/oss/state/newObservability/atoms/queries" import TimestampCell from "../../../TimestampCell" +import {useSessionAtomValue} from "../../assets/sessionCellStore" export const StartTimeCell = ({sessionId}: {sessionId: string}) => { - const isLoading = useAtomValue(sessionsLoadingAtom) - const {startTime} = useAtomValue(sessionTimeRangeAtomFamily(sessionId)) + const isLoading = useSessionAtomValue(sessionsLoadingAtom) + const {startTime} = useSessionAtomValue(sessionTimeRangeAtomFamily(sessionId)) if (isLoading) return if (!startTime) return <>- diff --git a/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/TotalCostCell.tsx b/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/TotalCostCell.tsx index a1b8e203e8..beb2b49eb6 100644 --- a/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/TotalCostCell.tsx +++ b/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/TotalCostCell.tsx @@ -1,5 +1,4 @@ import {Skeleton} from "antd" -import {useAtomValue} from "jotai" import { sessionCostAtomFamily, @@ -7,10 +6,11 @@ import { } from "@/oss/state/newObservability/atoms/queries" import CostCellDisplay from "../../../CostCell" +import {useSessionAtomValue} from "../../assets/sessionCellStore" export const TotalCostCell = ({sessionId}: {sessionId: string}) => { - const isLoading = useAtomValue(sessionsLoadingAtom) - const totalCost = useAtomValue(sessionCostAtomFamily(sessionId)) + const isLoading = useSessionAtomValue(sessionsLoadingAtom) + const totalCost = useSessionAtomValue(sessionCostAtomFamily(sessionId)) if (isLoading) return diff --git a/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/TotalLatencyCell.tsx b/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/TotalLatencyCell.tsx index 8c5c0f0748..2435444f0d 100644 --- a/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/TotalLatencyCell.tsx +++ b/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/TotalLatencyCell.tsx @@ -1,5 +1,4 @@ import {Skeleton} from "antd" -import {useAtomValue} from "jotai" import { sessionLatencyAtomFamily, @@ -7,10 +6,11 @@ import { } from "@/oss/state/newObservability/atoms/queries" import DurationCellDisplay from "../../../DurationCell" +import {useSessionAtomValue} from "../../assets/sessionCellStore" export const TotalLatencyCell = ({sessionId}: {sessionId: string}) => { - const isLoading = useAtomValue(sessionsLoadingAtom) - const totalLatency = useAtomValue(sessionLatencyAtomFamily(sessionId)) + const isLoading = useSessionAtomValue(sessionsLoadingAtom) + const totalLatency = useSessionAtomValue(sessionLatencyAtomFamily(sessionId)) if (isLoading) return diff --git a/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/TotalUsageCell.tsx b/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/TotalUsageCell.tsx index 8d3a5ccf02..768fccfa29 100644 --- a/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/TotalUsageCell.tsx +++ b/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/TotalUsageCell.tsx @@ -1,5 +1,4 @@ import {Skeleton} from "antd" -import {useAtomValue} from "jotai" import { sessionUsageAtomFamily, @@ -7,10 +6,11 @@ import { } from "@/oss/state/newObservability/atoms/queries" import UsageCellDisplay from "../../../UsageCell" +import {useSessionAtomValue} from "../../assets/sessionCellStore" export const TotalUsageCell = ({sessionId}: {sessionId: string}) => { - const isLoading = useAtomValue(sessionsLoadingAtom) - const totalUsage = useAtomValue(sessionUsageAtomFamily(sessionId)) + const isLoading = useSessionAtomValue(sessionsLoadingAtom) + const totalUsage = useSessionAtomValue(sessionUsageAtomFamily(sessionId)) if (isLoading) return diff --git a/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/TracesCountCell.tsx b/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/TracesCountCell.tsx index 90e4cab335..2114215e30 100644 --- a/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/TracesCountCell.tsx +++ b/web/oss/src/components/pages/observability/components/SessionsTable/components/Cells/TracesCountCell.tsx @@ -1,14 +1,15 @@ import {Skeleton} from "antd" -import {useAtomValue} from "jotai" import { sessionTraceCountAtomFamily, sessionsLoadingAtom, } from "@/oss/state/newObservability/atoms/queries" +import {useSessionAtomValue} from "../../assets/sessionCellStore" + export const TracesCountCell = ({sessionId}: {sessionId: string}) => { - const isLoading = useAtomValue(sessionsLoadingAtom) - const traceCount = useAtomValue(sessionTraceCountAtomFamily(sessionId)) + const isLoading = useSessionAtomValue(sessionsLoadingAtom) + const traceCount = useSessionAtomValue(sessionTraceCountAtomFamily(sessionId)) if (isLoading) return diff --git a/web/oss/src/components/pages/observability/components/SessionsTable/index.tsx b/web/oss/src/components/pages/observability/components/SessionsTable/index.tsx index 0d15650646..bdf3f09fb2 100644 --- a/web/oss/src/components/pages/observability/components/SessionsTable/index.tsx +++ b/web/oss/src/components/pages/observability/components/SessionsTable/index.tsx @@ -2,7 +2,7 @@ import {useCallback, useEffect, useMemo, useState} from "react" import {InfiniteVirtualTableFeatureShell} from "@agenta/ui/table" import type {TableFeaturePagination, TableScopeConfig} from "@agenta/ui/table" -import {useAtomValue, useSetAtom} from "jotai" +import {useAtomValue, useSetAtom, useStore} from "jotai" import dynamic from "next/dynamic" import {SessionDrawer} from "@/oss/components/SharedDrawers/SessionDrawer" @@ -17,6 +17,7 @@ import {AUTO_REFRESH_INTERVAL} from "../../constants" import EmptySessions from "./assets/EmptySessions" import {getSessionColumns, SessionRow} from "./assets/getSessionColumns" +import {SessionStoreProvider} from "./assets/sessionCellStore" const ObservabilityHeader = dynamic(() => import("../../components/ObservabilityHeader"), { ssr: false, @@ -49,6 +50,10 @@ const SessionsTable: React.FC = () => { resetSessionPages, } = useSessions() + // The store the page lives in (has projectId + the session/span queries + their data). The + // table renders rows in its own isolated store, so cells are handed this one via context. + const pageStore = useStore() + const isNewUser = useAtomValue(isNewUserAtom) const onboardingStorageUserId = useAtomValue(onboardingStorageUserIdAtom) const openDrawer = useSetAtom(openSessionDrawerWithUrlAtom) @@ -111,44 +116,45 @@ const SessionsTable: React.FC = () => { const isEmptyState = sessionIds.length === 0 && !isLoading return ( -
- - - {isEmptyState ? ( - - ) : ( - - tableScope={tableScope} + +
+ ({ - onClick: () => openDrawer({sessionId: record.session_id}), - style: {cursor: "pointer"}, - }), - }} + componentType="sessions" + isLoading={isLoading} + onRefresh={handleRefresh} + realtimeMode={realtimeMode} + setRealtimeMode={setRealtimeMode} + autoRefresh={autoRefresh} + setAutoRefresh={setAutoRefresh} + refreshTrigger={refreshTrigger} /> - )} - -
+ + {isEmptyState ? ( + + ) : ( + + tableScope={tableScope} + columns={columns} + rowKey="session_id" + pagination={pagination} + resizableColumns + enableExport={false} + useSettingsDropdown={false} + className="flex-1 min-h-0 [&_.ant-table-tbody_.ant-table-cell]:align-top" + tableProps={{ + bordered: true, + loading: isLoading && sessionIds.length === 0, + onRow: (record) => ({ + onClick: () => openDrawer({sessionId: record.session_id}), + style: {cursor: "pointer"}, + }), + }} + /> + )} + +
+ ) } diff --git a/web/oss/src/components/pages/prompts/assets/iconHelpers.tsx b/web/oss/src/components/pages/prompts/assets/iconHelpers.tsx index 1902e21c55..2864a5c011 100644 --- a/web/oss/src/components/pages/prompts/assets/iconHelpers.tsx +++ b/web/oss/src/components/pages/prompts/assets/iconHelpers.tsx @@ -1,6 +1,6 @@ import React from "react" -import {ChatDotsIcon, NoteIcon} from "@phosphor-icons/react" +import {ChatDotsIcon, NoteIcon, RobotIcon} from "@phosphor-icons/react" import CompletionAppIcon from "../components/CompletionAppIcon" import SetupWorkflowIcon from "../components/SetupWorkflowIcon" @@ -8,6 +8,8 @@ import SetupWorkflowIcon from "../components/SetupWorkflowIcon" export const getAppTypeIcon = (appType?: string) => { const normalizedType = appType?.toLowerCase() + if (normalizedType?.includes("agent")) + return if (normalizedType?.includes("chat")) return if (normalizedType?.includes("completion")) diff --git a/web/oss/src/lib/helpers/dynamicEnv.ts b/web/oss/src/lib/helpers/dynamicEnv.ts index 567a98ad13..afac57ed58 100644 --- a/web/oss/src/lib/helpers/dynamicEnv.ts +++ b/web/oss/src/lib/helpers/dynamicEnv.ts @@ -4,6 +4,14 @@ export const processEnv = { NEXT_PUBLIC_AGENTA_API_URL: process.env.NEXT_PUBLIC_AGENTA_API_URL, NEXT_PUBLIC_POSTHOG_API_KEY: process.env.NEXT_PUBLIC_POSTHOG_API_KEY, NEXT_PUBLIC_CRISP_WEBSITE_ID: process.env.NEXT_PUBLIC_CRISP_WEBSITE_ID, + // Feature flag for the agent chat streaming slice (contract v1) page. + NEXT_PUBLIC_AGENT_CHAT_SLICE: process.env.NEXT_PUBLIC_AGENT_CHAT_SLICE, + // Streaming endpoint the agent chat slice points `useChat` at. Defaults to the + // local RAG_QA contract mock when unset (see AgentChatSlice/assets/constants.ts). + NEXT_PUBLIC_AGENT_CHAT_API: process.env.NEXT_PUBLIC_AGENT_CHAT_API, + // Default request-contract track for the agent chat slice: "uimessage" (Track A) or + // "agenta" (Track B). The page also has a runtime toggle. + NEXT_PUBLIC_AGENT_CHAT_TRACK: process.env.NEXT_PUBLIC_AGENT_CHAT_TRACK, NEXT_PUBLIC_AGENTA_AUTHN_EMAIL: process.env.NEXT_PUBLIC_AGENTA_AUTHN_EMAIL, NEXT_PUBLIC_AGENTA_AUTH_GOOGLE_OAUTH_CLIENT_ID: process.env.NEXT_PUBLIC_AGENTA_AUTH_GOOGLE_OAUTH_CLIENT_ID, diff --git a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/agent-chat/index.tsx b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/agent-chat/index.tsx new file mode 100644 index 0000000000..e11a907fc9 --- /dev/null +++ b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/agent-chat/index.tsx @@ -0,0 +1,34 @@ +import {Typography} from "antd" +import dynamic from "next/dynamic" + +import {isAgentChatSliceEnabled} from "@/oss/components/AgentChatSlice/assets/constants" +import {useBreadcrumbsEffect} from "@/oss/lib/hooks/useBreadcrumbs" + +// Client-only: `useChat` and the streaming transport are browser concerns. +const AgentChatSlice = dynamic(() => import("@/oss/components/AgentChatSlice"), {ssr: false}) + +/** + * Feature-flagged route for the agent chat streaming slice (contract v1). + * Enable with `NEXT_PUBLIC_AGENT_CHAT_SLICE=true`. + */ +const AgentChatPage = () => { + useBreadcrumbsEffect({breadcrumbs: {"agent-chat": {label: "Agent chat"}}}, []) + + if (!isAgentChatSliceEnabled()) { + return ( +
+ + Agent chat slice is disabled. Set NEXT_PUBLIC_AGENT_CHAT_SLICE=true to enable. + +
+ ) + } + + return ( +
+ +
+ ) +} + +export default AgentChatPage diff --git a/web/oss/src/state/newObservability/atoms/queries.ts b/web/oss/src/state/newObservability/atoms/queries.ts index 29b74ee98c..a031cc64e4 100644 --- a/web/oss/src/state/newObservability/atoms/queries.ts +++ b/web/oss/src/state/newObservability/atoms/queries.ts @@ -594,6 +594,32 @@ export const sessionFirstInputAtomFamily = atomFamily((sessionId: string) => }), ) +// A streamed agent run's ROOT workflow span returns a generator, so its `ag.data.outputs` is +// the generator object's repr (``), not the reply — the +// span is already closed by the time the stream produces text. The real assistant output is on +// the nested `agent`-type span (e.g. `invoke_agent`). So prefer that span's output, falling back +// to the root's unless it's the meaningless generator repr. +const GENERATOR_REPR = /^<(?:async_)?generator object/ + +const traceDisplayOutput = (root: TraceSpanNode): unknown => { + let agentOutput: unknown + const visit = (node: TraceSpanNode) => { + if (agentOutput !== undefined) return + if (node.span_type === "agent") { + const out = (node.attributes as any)?.ag?.data?.outputs + if (out !== undefined) agentOutput = out + } + node.children?.forEach((child) => visit(child as TraceSpanNode)) + } + visit(root) + if (agentOutput !== undefined) return agentOutput + + const rootOutput = (root.attributes as any)?.ag?.data?.outputs + return typeof rootOutput === "string" && GENERATOR_REPR.test(rootOutput) + ? undefined + : rootOutput +} + export const sessionLastOutputAtomFamily = atomFamily((sessionId: string) => atom((get) => { const sorted = get(sessionSortedTracesAtomFamily(sessionId)) @@ -603,7 +629,7 @@ export const sessionLastOutputAtomFamily = atomFamily((sessionId: string) => if (lastTrace.status_code === "STATUS_CODE_ERROR") { return lastTrace.status_message } - return (lastTrace.attributes as any)?.ag?.data?.outputs + return traceDisplayOutput(lastTrace) }), ) diff --git a/web/oss/src/state/newObservability/selectors/tracing.ts b/web/oss/src/state/newObservability/selectors/tracing.ts index 2b2c200a41..0596e498e0 100644 --- a/web/oss/src/state/newObservability/selectors/tracing.ts +++ b/web/oss/src/state/newObservability/selectors/tracing.ts @@ -34,7 +34,32 @@ export const getLatency = (span?: TraceSpanNode) => export const getTraceInputs = (span?: TraceSpanNode) => span?.attributes?.ag?.data?.inputs ?? null -export const getTraceOutputs = (span?: TraceSpanNode) => span?.attributes?.ag?.data?.outputs ?? null +// A streamed agent run's ROOT span returns a generator, so its `ag.data.outputs` is the +// generator object's repr (``), not the reply — the span +// is closed before the stream produces text. The real assistant output lives on the nested +// `agent`-type span (`invoke_agent`). +const GENERATOR_REPR = /^<(?:async_)?generator object/ + +const spanOutputs = (span: TraceSpanNode): unknown => (span.attributes as any)?.ag?.data?.outputs + +export const getTraceOutputs = (span?: TraceSpanNode): unknown => { + if (!span) return null + // Prefer the nested agent span's output (the assistant's reply) over the root's generator. + let agentOutput: unknown + const visit = (node: TraceSpanNode) => { + if (agentOutput !== undefined) return + if (node.span_type === "agent") { + const out = spanOutputs(node) + if (out !== undefined && out !== null) agentOutput = out + } + node.children?.forEach((child) => visit(child as TraceSpanNode)) + } + visit(span) + if (agentOutput !== undefined) return agentOutput + + const own = spanOutputs(span) ?? null + return typeof own === "string" && GENERATOR_REPR.test(own) ? null : own +} // General attribute helpers ---------------------------------------------------- export const getAgMetaConfiguration = (span?: TraceSpanNode) => diff --git a/web/packages/agenta-entities/src/loadable/controller.ts b/web/packages/agenta-entities/src/loadable/controller.ts index 7ea6d94ee7..f28bc62207 100644 --- a/web/packages/agenta-entities/src/loadable/controller.ts +++ b/web/packages/agenta-entities/src/loadable/controller.ts @@ -1596,6 +1596,70 @@ const getRootSpanFromTraceResponse = ( return spans.find((s) => !s.parent_id) || spans[0] } +/** + * Scan every span in the trace for an errored one and return its status message. Used to + * surface a failed model/tool call (e.g. an OpenAI quota error) that the run swallowed into an + * empty turn — the error lands on a leaf span (`chat …`) while the parents stay OK, so we + * check all spans, not just the root. + */ +const spanErrorMessage = (span: unknown): string | undefined => { + const s = span as { + status_code?: string + status_message?: string + status?: {code?: string; message?: string} + events?: {name?: string; attributes?: Record}[] + } + const code = s.status_code ?? s.status?.code + const isError = typeof code === "string" && code.toUpperCase().includes("ERROR") + + // Error text can live on an OTel `exception` event (preferred) or `status_message`. + const exc = Array.isArray(s.events) + ? s.events.find((e) => e?.name === "exception")?.attributes + : undefined + const excMsg = exc?.["exception.message"] ?? exc?.message + const message = + (typeof excMsg === "string" && excMsg.trim() && excMsg.trim()) || + (typeof s.status_message === "string" && + s.status_message.trim() && + s.status_message.trim()) || + (typeof s.status?.message === "string" && + s.status.message.trim() && + s.status.message.trim()) || + undefined + + if (!isError && !message) return undefined + return message || "The run failed." +} + +/** Children are nested under each span's `spans` (object map or array), not flat. */ +const childSpans = (span: unknown): unknown[] => { + const kids = (span as {spans?: unknown}).spans + if (Array.isArray(kids)) return kids + if (kids && typeof kids === "object") return Object.values(kids) + return [] +} + +const getTraceErrorFromResponse = (traceResponse: TracesApiResponse | null): string | undefined => { + if (!traceResponse?.traces) return undefined + const visit = (span: unknown): string | undefined => { + const message = spanErrorMessage(span) + if (message) return message + for (const child of childSpans(span)) { + const m = visit(child) + if (m) return m + } + return undefined + } + for (const traceEntry of Object.values(traceResponse.traces)) { + const spans = traceEntry?.spans ? Object.values(traceEntry.spans) : [] + for (const span of spans) { + const message = visit(span) + if (message) return message + } + } + return undefined +} + // ============================================================================ // TRACE DATA SUMMARY - Single source of truth for trace-derived data // ============================================================================ @@ -1625,6 +1689,8 @@ export interface TraceDataSummary { rootSpan: TraceSpan | null /** Extracted ag.data object */ agData: Record | null + /** Status message of the first errored span, if the run failed (e.g. an API/quota error). */ + error?: string } /** @@ -1707,10 +1773,14 @@ export const traceDataSummaryAtomFamily = atomFamily((traceId: string | null) => return emptyResult } + // A failed model/tool call (e.g. quota error) lands on a leaf span — capture it so the + // caller can surface it even if the run otherwise looks like an empty turn. + const error = getTraceErrorFromResponse(traceQuery.data) + // Get the root span const rootSpan = getRootSpanFromTraceResponse(traceQuery.data) if (!rootSpan) { - return emptyResult + return {...emptyResult, error} } // Extract ag.data @@ -1773,6 +1843,7 @@ export const traceDataSummaryAtomFamily = atomFamily((traceId: string | null) => metrics, rootSpan, agData, + error, } }), ) diff --git a/web/packages/agenta-entities/src/workflow/core/schema.ts b/web/packages/agenta-entities/src/workflow/core/schema.ts index 8ab4d16a3d..1d59842515 100644 --- a/web/packages/agenta-entities/src/workflow/core/schema.ts +++ b/web/packages/agenta-entities/src/workflow/core/schema.ts @@ -95,6 +95,9 @@ export const workflowFlagsSchema = z is_feedback: z.boolean().optional().default(false), // Interface-derived is_chat: z.boolean().optional().default(false), + // Agent workflows (WP-6). Backend-owned; until it lands, agent detection + // falls back to a config heuristic in `isAgentModeAtomFamily`. + is_agent: z.boolean().optional().default(false), has_url: z.boolean().optional().default(false), has_script: z.boolean().optional().default(false), has_handler: z.boolean().optional().default(false), @@ -339,6 +342,7 @@ export const workflowSchemas = createEntitySchemaSet({ // URI-derived is_managed: false, is_custom: false, + is_agent: false, is_llm: false, is_hook: false, is_code: false, diff --git a/web/packages/agenta-entities/src/workflow/state/appUtils.ts b/web/packages/agenta-entities/src/workflow/state/appUtils.ts index de72d61b38..6216e2acf6 100644 --- a/web/packages/agenta-entities/src/workflow/state/appUtils.ts +++ b/web/packages/agenta-entities/src/workflow/state/appUtils.ts @@ -64,7 +64,7 @@ export const appTemplatesDataAtom = atom((get) => { * App types supported by the drawer flow. "custom" routes through the * existing CustomWorkflowModal and does NOT use this factory. */ -export type AppType = "chat" | "completion" +export type AppType = "chat" | "completion" | "agent" export interface CreateEphemeralAppFromTemplateParams { type: AppType @@ -206,7 +206,9 @@ export async function createEphemeralAppFromTemplate({ is_code: false, is_match: false, is_feedback: false, - is_chat: type === "chat", + // Agent takes messages-in / returns a final message, so it runs in + // chat mode like `chat` (backend infers is_chat from messages-in too). + is_chat: type === "chat" || type === "agent", has_url: false, has_script: false, has_handler: false, diff --git a/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts b/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts index e36531a10c..873a61e7ff 100644 --- a/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts +++ b/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts @@ -924,6 +924,7 @@ export async function createEvaluatorFromTemplate(templateKey: string): Promise< is_match: false, is_feedback: false, is_chat: false, + is_agent: false, has_url: false, has_script: false, has_handler: false, diff --git a/web/packages/agenta-entities/src/workflow/state/helpers.ts b/web/packages/agenta-entities/src/workflow/state/helpers.ts index 8ad143f203..ef13c5a7bd 100644 --- a/web/packages/agenta-entities/src/workflow/state/helpers.ts +++ b/web/packages/agenta-entities/src/workflow/state/helpers.ts @@ -233,6 +233,7 @@ export function deriveWorkflowTypeFromRevision( } // Apps: URI is the source of truth. Format: provider:kind:key:version. + if (uriKey === "agent") return "agent" if (uriKey === "chat") return "chat" if (uriKey === "completion") return "completion" if (uriKey === "llm") return "llm" @@ -241,7 +242,9 @@ export function deriveWorkflowTypeFromRevision( if (uriKey === "match") return "match" if (uriKey === "feedback") return "human" - // Fallback to flags for apps without a matching URI kind. + // Fallback to flags for apps without a matching URI kind. Agent wins over + // is_custom/is_chat (an agent currently surfaces as custom + is_chat). + if (flags?.is_agent) return "agent" if (flags?.is_custom) return "custom" if (flags?.is_chat) return "chat" diff --git a/web/packages/agenta-entities/src/workflow/state/molecule.ts b/web/packages/agenta-entities/src/workflow/state/molecule.ts index 0bc1186007..ac5b2934ad 100644 --- a/web/packages/agenta-entities/src/workflow/state/molecule.ts +++ b/web/packages/agenta-entities/src/workflow/state/molecule.ts @@ -300,6 +300,11 @@ const isChatAtomFamily = atomFamily((workflowId: string) => atom((get) => get(flagsAtomFamily(workflowId))?.is_chat ?? false), ) +/** Is an agent workflow (WP-6, backend-owned). */ +const isAgentAtomFamily = atomFamily((workflowId: string) => + atom((get) => get(flagsAtomFamily(workflowId))?.is_agent ?? false), +) + /** Has a webhook/service URL. */ const hasUrlAtomFamily = atomFamily((workflowId: string) => atom((get) => get(flagsAtomFamily(workflowId))?.has_url ?? false), @@ -356,6 +361,7 @@ export type WorkflowType = | "hook" | "match" | "custom" + | "agent" | "chat" | "completion" @@ -367,6 +373,10 @@ const workflowTypeAtomFamily = atomFamily((workflowId: string) => if (flags?.is_code) return "code" if (flags?.is_hook) return "hook" if (flags?.is_match) return "match" + // Agent wins over `is_custom`/`is_chat`: an SDK-deployed agent currently + // surfaces as custom and is forced through chat by also flagging is_chat. + // Once WP-6 sets is_agent, that takes precedence so the agent lane is chosen. + if (flags?.is_agent) return "agent" if (flags?.is_custom) return "custom" if (flags?.is_chat) return "chat" return "completion" @@ -1389,6 +1399,8 @@ export const workflowMolecule = { // Interface-derived /** Has chat/message semantics */ isChat: isChatAtomFamily, + /** Is an agent workflow (WP-6) */ + isAgent: isAgentAtomFamily, /** Has a webhook/service URL */ hasUrl: hasUrlAtomFamily, /** Has embedded script content */ @@ -1532,6 +1544,8 @@ export const workflowMolecule = { getStore(options).get(isHumanAtomFamily(workflowId)), isChat: (workflowId: string, options?: StoreOptions) => getStore(options).get(isChatAtomFamily(workflowId)), + isAgent: (workflowId: string, options?: StoreOptions) => + getStore(options).get(isAgentAtomFamily(workflowId)), hasUrl: (workflowId: string, options?: StoreOptions) => getStore(options).get(hasUrlAtomFamily(workflowId)), hasScript: (workflowId: string, options?: StoreOptions) => diff --git a/web/packages/agenta-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index 55ee45f049..ad0d65c2d0 100644 --- a/web/packages/agenta-entities/src/workflow/state/store.ts +++ b/web/packages/agenta-entities/src/workflow/state/store.ts @@ -2176,6 +2176,7 @@ export function createEphemeralWorkflow(params: CreateEphemeralWorkflowParams): is_match: false, is_feedback: false, is_chat: isChat, + is_agent: false, has_url: false, has_script: false, has_handler: false, diff --git a/web/packages/agenta-entities/tests/unit/derive-workflow-type-agent.test.ts b/web/packages/agenta-entities/tests/unit/derive-workflow-type-agent.test.ts new file mode 100644 index 0000000000..e5d5e7986f --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/derive-workflow-type-agent.test.ts @@ -0,0 +1,55 @@ +/** + * Unit tests for the `agent` arm of workflow-type derivation (WP-6). + * + * `deriveWorkflowTypeFromRevision` maps a revision to a single UI rendering + * category. Agent is detected two ways, in priority order: + * 1. URI kind — `provider:kind:agent:version` (the 3rd `:`-segment is the key). + * 2. Flag fallback — `is_agent` wins over `is_custom`/`is_chat`, because an + * SDK-deployed agent currently surfaces as custom + is_chat for back-compat. + * + * These cases guard the disjointness the playground branch relies on: an agent + * must never resolve to "chat" or "custom" once it carries the agent signal. + */ + +import {describe, it, expect} from "vitest" + +import {deriveWorkflowTypeFromRevision} from "../../src/workflow/state/helpers" + +// Minimal revision shape — only the fields the function reads. +const rev = (over: {uri?: string; slug?: string; flags?: Record}) => + ({ + slug: over.slug, + data: over.uri ? {uri: over.uri} : undefined, + flags: over.flags ?? {}, + }) as any + +describe("deriveWorkflowTypeFromRevision — agent", () => { + it("resolves an agent URI kind to 'agent'", () => { + // provider:kind:KEY:version → key segment === "agent" + expect(deriveWorkflowTypeFromRevision(rev({uri: "agenta:serve:agent:v0.1"}))).toBe("agent") + }) + + it("resolves the is_agent flag to 'agent' when no URI kind matches", () => { + expect(deriveWorkflowTypeFromRevision(rev({flags: {is_agent: true}}))).toBe("agent") + }) + + it("lets is_agent win over is_chat (back-compat flagging)", () => { + expect(deriveWorkflowTypeFromRevision(rev({flags: {is_agent: true, is_chat: true}}))).toBe( + "agent", + ) + }) + + it("lets is_agent win over is_custom (agents currently surface as custom)", () => { + expect( + deriveWorkflowTypeFromRevision(rev({flags: {is_agent: true, is_custom: true}})), + ).toBe("agent") + }) + + // Regression: the new agent arm must not perturb existing resolution. + it("still resolves chat / custom / completion unchanged", () => { + expect(deriveWorkflowTypeFromRevision(rev({flags: {is_chat: true}}))).toBe("chat") + expect(deriveWorkflowTypeFromRevision(rev({flags: {is_custom: true}}))).toBe("custom") + expect(deriveWorkflowTypeFromRevision(rev({}))).toBe("completion") + expect(deriveWorkflowTypeFromRevision(rev({uri: "agenta:serve:chat:v0.1"}))).toBe("chat") + }) +}) diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentConfigControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentConfigControl.tsx new file mode 100644 index 0000000000..cbd21e258a --- /dev/null +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentConfigControl.tsx @@ -0,0 +1,670 @@ +/** + * AgentConfigControl + * + * One composite control for the whole agent config, dispatched from + * `x-ag-type: "agent_config"` / `x-ag-type-ref: "agent_config"` (see SchemaPropertyRenderer). + * It reuses the existing controls rather than inventing new ones: the model selector + * (GroupedChoiceControl), the tool picker (ToolSelectorPopover + ToolItemControl), the MCP + * server editor (McpServerItemControl), the skill editor (SkillConfigControl), enum selects + * (harness, sandbox, permission policy), and a textarea (agents_md). The field shape is the + * `agent_config` catalog type generated + * from the SDK model (AgentConfigSchema in agenta.sdk.utils.types); the agent service ships a + * thin `x-ag-type-ref` the playground resolves and reads back (services/oss/src/agent). + */ +import {useCallback, useMemo, useState} from "react" + +import type {SchemaProperty} from "@agenta/entities/shared" +import {LabeledField} from "@agenta/ui/components/presentational" +import {useDrillInUI} from "@agenta/ui/drill-in" +import {cn} from "@agenta/ui/styles" +import {CaretDown, CaretRight, Plus} from "@phosphor-icons/react" +import {Button, Select, Switch, Typography} from "antd" + +import {ClaudePermissionsControl} from "./ClaudePermissionsControl" +import { + allowedConnectionModes, + allowedProviders, + composeModelValue, + connectionFromConfig, + modelIdFromConfig, + type ConnectionMode, +} from "./connectionUtils" +import {EnumSelectControl} from "./EnumSelectControl" +import {GroupedChoiceControl} from "./GroupedChoiceControl" +import {McpServerItemControl} from "./McpServerItemControl" +import {SandboxPermissionControl} from "./SandboxPermissionControl" +import {isPlatformSkill, SkillConfigControl} from "./SkillConfigControl" +import {TextInputControl} from "./TextInputControl" +import {ToolItemControl} from "./ToolItemControl" +import {ToolSelectorPopover, type ToolSelectionMeta} from "./ToolSelectorPopover" +import {type ToolObj} from "./toolUtils" + +const CONNECTION_MODE_LABELS: Record = { + default: "Project default", + self_managed: "Self-managed", + agenta: "Agenta connection", +} + +export interface AgentConfigControlProps { + schema?: SchemaProperty | null + label?: string + value?: Record | null + onChange: (value: Record) => void + description?: string + withTooltip?: boolean + disabled?: boolean + className?: string +} + +/** Read the function name of a tool object (the gateway slug for Composio tools). */ +function toolName(tool: unknown): string | undefined { + if (!tool || typeof tool !== "object") return undefined + const fn = (tool as Record).function + if (!fn || typeof fn !== "object") return undefined + const name = (fn as Record).name + return typeof name === "string" ? name : undefined +} + +function isBuiltinPayloadMatch(tool: unknown, payload: ToolObj): boolean { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false + + const toolObj = tool as Record + const payloadObj = payload as Record + + if (typeof payloadObj.type === "string" && toolObj.type === payloadObj.type) return true + if (typeof payloadObj.name === "string" && toolObj.name === payloadObj.name) return true + + const payloadKeys = Object.keys(payloadObj) + return ( + payloadKeys.length === 1 && + payloadKeys[0] !== "type" && + payloadKeys[0] !== "name" && + payloadKeys[0] in toolObj + ) +} + +export function AgentConfigControl({ + schema, + value, + onChange, + withTooltip, + disabled, + className, +}: AgentConfigControlProps) { + const {EditorProvider, SharedEditor, gatewayTools} = useDrillInUI() + const config = (value ?? {}) as Record + const props = (schema?.properties ?? {}) as Record + + // Update a single field of the agent config, leaving the rest intact. + const setField = useCallback( + (key: string, fieldValue: unknown) => onChange({...config, [key]: fieldValue}), + [config, onChange], + ) + + // Model + credential connection (the ModelRef). `config.model` is either a plain string + // (the default connection, kept byte-identical to today) or a structured object the SDK + // coerces into a ModelRef. The form edits the fields directly via composeModelValue. + const harness = typeof config.harness === "string" ? config.harness : null + const modelId = useMemo(() => modelIdFromConfig(config.model), [config.model]) + const connection = useMemo(() => connectionFromConfig(config.model), [config.model]) + const providerOptions = useMemo(() => allowedProviders(harness), [harness]) + const providersOpen = providerOptions.includes("*") + const modeOptions = useMemo(() => allowedConnectionModes(harness), [harness]) + + // Compose the new `config.model` from the current connection fields, overriding one of + // them. Empty provider/slug clear that part of the structured value. + const writeModel = useCallback( + (patch: { + modelId?: string | null + provider?: string | null + mode?: ConnectionMode + slug?: string | null + }) => + setField( + "model", + composeModelValue({ + modelId: patch.modelId !== undefined ? patch.modelId : modelId, + provider: patch.provider !== undefined ? patch.provider : connection.provider, + mode: patch.mode !== undefined ? patch.mode : connection.mode, + slug: patch.slug !== undefined ? patch.slug : connection.slug, + // Carry through extra ModelRef keys (params, ...) the form does not edit. + existing: config.model, + }), + ), + [setField, modelId, connection, config.model], + ) + + // Raw-JSON escape hatch for the whole `config.model` value (collapsed by default). + const [showModelJson, setShowModelJson] = useState(false) + const [modelJsonText, setModelJsonText] = useState(() => + JSON.stringify(config.model ?? "", null, 2), + ) + const handleModelJsonChange = useCallback( + (text: string) => { + setModelJsonText(text) + try { + setField("model", text ? JSON.parse(text) : "") + } catch { + // Keep the invalid text in the editor; don't propagate until it parses. + } + }, + [setField], + ) + const handleToggleModelJson = useCallback( + (next: boolean) => { + if (next) setModelJsonText(JSON.stringify(config.model ?? "", null, 2)) + setShowModelJson(next) + }, + [config.model], + ) + + // Tools live as a flat array on the agent config (the same tool-object shape the + // prompt control uses, so the backend resolver parses them identically). + const tools = useMemo( + () => (Array.isArray(config.tools) ? (config.tools as unknown[]) : []), + [config.tools], + ) + const setTools = useCallback((next: unknown[]) => setField("tools", next), [setField]) + + const handleAddTool = useCallback( + (tool: ToolObj, meta?: ToolSelectionMeta) => { + const next = + meta && tool && typeof tool === "object" && !Array.isArray(tool) + ? { + ...(tool as Record), + agenta_metadata: { + ...(((tool as Record).agenta_metadata as + | Record + | undefined) ?? {}), + ...meta, + }, + } + : tool + setTools([...tools, next]) + }, + [tools, setTools], + ) + + const handleToolChange = useCallback( + (index: number, next: ToolObj) => { + const updated = [...tools] + updated[index] = next + setTools(updated) + }, + [tools, setTools], + ) + + const handleToolDelete = useCallback( + (index: number) => setTools(tools.filter((_, i) => i !== index)), + [tools, setTools], + ) + + const handleRemoveToolByName = useCallback( + (name: string) => setTools(tools.filter((tool) => toolName(tool) !== name)), + [tools, setTools], + ) + + const handleRemoveBuiltinTool = useCallback( + (toolToRemove: ToolObj) => { + let removed = false + const updated = tools.filter((tool) => { + if (removed) return true + if (!isBuiltinPayloadMatch(tool, toolToRemove)) return true + removed = true + return false + }) + if (removed) setTools(updated) + }, + [tools, setTools], + ) + + const selectedToolNames = useMemo( + () => new Set(tools.map(toolName).filter((n): n is string => Boolean(n))), + [tools], + ) + + // MCP servers are a sibling of tools: a flat array on the agent config. Each entry is the + // open McpServer shape (name + stdio command/args/env or remote url, secret names), edited + // as JSON the backend resolver parses identically to `tools`. + const mcpServers = useMemo( + () => (Array.isArray(config.mcp_servers) ? (config.mcp_servers as unknown[]) : []), + [config.mcp_servers], + ) + const setMcpServers = useCallback( + (next: unknown[]) => setField("mcp_servers", next), + [setField], + ) + const handleAddMcpServer = useCallback( + () => setMcpServers([...mcpServers, {name: "", transport: "stdio", command: "", args: []}]), + [mcpServers, setMcpServers], + ) + const handleMcpServerChange = useCallback( + (index: number, next: Record) => { + const updated = [...mcpServers] + updated[index] = next + setMcpServers(updated) + }, + [mcpServers, setMcpServers], + ) + const handleMcpServerDelete = useCallback( + (index: number) => setMcpServers(mcpServers.filter((_, i) => i !== index)), + [mcpServers, setMcpServers], + ) + + // Skills are a sibling of tools/mcp_servers: a flat array on the agent config. Each entry is + // either an inline SKILL.md package (name + description + body + optional files/flags) or an + // `@ag.embed` reference the backend inlines into that same shape. Both are edited as JSON the + // backend resolver parses identically; an embed entry round-trips intact (see SkillConfigControl). + const skills = useMemo( + () => (Array.isArray(config.skills) ? (config.skills as unknown[]) : []), + [config.skills], + ) + const setSkills = useCallback((next: unknown[]) => setField("skills", next), [setField]) + const handleAddSkill = useCallback( + () => setSkills([...skills, {name: "", description: "", body: ""}]), + [skills, setSkills], + ) + const handleSkillChange = useCallback( + (index: number, next: Record) => { + const updated = [...skills] + updated[index] = next + setSkills(updated) + }, + [skills, setSkills], + ) + const handleSkillDelete = useCallback( + (index: number) => setSkills(skills.filter((_, i) => i !== index)), + [skills, setSkills], + ) + + // Layer 2: the sandbox security boundary (`sandbox_permission`). Applies to every harness. + // Stored as a nested object; an unset value stays null until the author changes something. + const sandboxPermission = useMemo( + () => + config.sandbox_permission && typeof config.sandbox_permission === "object" + ? (config.sandbox_permission as Record) + : null, + [config.sandbox_permission], + ) + + // Layer 1 (Claude-only): the Claude harness's own permission knobs, persisted into the neutral + // `harness_options.claude.permissions` bag. Hidden when the harness is not Claude. + const harnessOptions = useMemo( + () => + config.harness_options && typeof config.harness_options === "object" + ? (config.harness_options as Record) + : {}, + [config.harness_options], + ) + const claudePermissions = useMemo(() => { + const claude = harnessOptions.claude + const claudeObj = + claude && typeof claude === "object" ? (claude as Record) : undefined + const perms = claudeObj?.permissions + return perms && typeof perms === "object" ? (perms as Record) : null + }, [harnessOptions]) + // Write `harness_options.claude.permissions`, preserving any other harness_options slices. + const setClaudePermissions = useCallback( + (next: Record) => { + const claude = + harnessOptions.claude && typeof harnessOptions.claude === "object" + ? (harnessOptions.claude as Record) + : {} + setField("harness_options", { + ...harnessOptions, + claude: {...claude, permissions: next}, + }) + }, + [harnessOptions, setField], + ) + const [showClaudeAdvanced, setShowClaudeAdvanced] = useState(false) + + // ``agents_md`` is the catalog-schema field; ``instructions`` is read as a fallback so an + // already-stored agent config (the legacy key) still populates the editor. + const agentsMd = + (config.agents_md as string | null | undefined) ?? + (config.instructions as string | null | undefined) ?? + null + + return ( +
+ setField("agents_md", v)} + description={props.agents_md?.description as string | undefined} + withTooltip={withTooltip} + disabled={disabled} + multiline + /> + + writeModel({modelId: v})} + withTooltip={withTooltip} + disabled={disabled} + /> + + {/* Connection (provider + credential mode + slug for the ModelRef) */} +
+ Connection + + + {providersOpen ? ( + writeModel({provider: v || null})} + withTooltip={false} + disabled={disabled} + placeholder="e.g. openai (optional)" + /> + ) : ( +