diff --git a/services/studio/src/nmp/studio/coding_agent_artifacts.py b/services/studio/src/nmp/studio/coding_agent_artifacts.py index 83d407360d..1a64852223 100644 --- a/services/studio/src/nmp/studio/coding_agent_artifacts.py +++ b/services/studio/src/nmp/studio/coding_agent_artifacts.py @@ -165,16 +165,23 @@ def _decode_answer_pair_value(value: str) -> str: return decoded if isinstance(decoded, str) else value +def answer_selection_pairs(text: str) -> list[tuple[str, str]]: + """Return the question and answer pairs persisted by AskUserQuestion.""" + pairs: list[tuple[str, str]] = [] + for match in _ANSWER_PAIR_RE.finditer(text): + question = _decode_answer_pair_value(match.group(1)).strip() + answer = _decode_answer_pair_value(match.group(2)).strip() + if question and answer: + pairs.append((question, answer)) + return pairs + + def record_answer_selections( artifacts: ChatArtifactsResponse, text: str, question_labels: dict[str, str] | None = None, ) -> None: - for match in _ANSWER_PAIR_RE.finditer(text): - question = _decode_answer_pair_value(match.group(1)).strip() - answer = _decode_answer_pair_value(match.group(2)).strip() - if not question or not answer: - continue + for question, answer in answer_selection_pairs(text): label = question_labels.get(question) if question_labels else None _set_selection_artifact(artifacts, label or _selection_label(question), answer) diff --git a/services/studio/src/nmp/studio/coding_agent_mcp_tools.py b/services/studio/src/nmp/studio/coding_agent_mcp_tools.py index d6d41be457..634f79795f 100644 --- a/services/studio/src/nmp/studio/coding_agent_mcp_tools.py +++ b/services/studio/src/nmp/studio/coding_agent_mcp_tools.py @@ -226,12 +226,14 @@ ), ( "For clarification, multiple-choice, yes/no, or freeform questions that do NOT map to one of " - "the select_* tools, use Claude Code's AskUserQuestion tool rather than writing a " - "questionnaire in markdown." + "the select_* tools, use Claude Code's AskUserQuestion tool rather than a questionnaire " + "in markdown." ), ( - "Only fall back to plain chat questions when no suitable UI tool is available, the user " - "already provided the value, or the UI tool returns skipped or error." + "Only fall back to plain chat questions when no suitable UI tool exists, the user already " + "provided the value, or the user explicitly skips the UI tool. A timeout, disconnect, or " + "other UI-tool error is not permission to continue or repeat the question in plain text; " + "leave the input unresolved and tell the user the interactive request must be retried." ), ( "Set UI tool titles, descriptions, display labels, and output_key values to match the " diff --git a/services/studio/src/nmp/studio/coding_agents.py b/services/studio/src/nmp/studio/coding_agents.py index b9693902cd..bf58cdc1bd 100644 --- a/services/studio/src/nmp/studio/coding_agents.py +++ b/services/studio/src/nmp/studio/coding_agents.py @@ -9,7 +9,7 @@ import os import shutil import uuid -from collections.abc import AsyncIterator, Mapping +from collections.abc import AsyncIterator, Awaitable, Mapping from dataclasses import dataclass from dataclasses import field as dataclass_field from pathlib import Path @@ -21,6 +21,7 @@ from nmp.studio import studio_links from nmp.studio.coding_agent_artifacts import ( ChatArtifactsResponse, + answer_selection_pairs, record_answer_selections, record_coding_agent_model, record_spec_text_artifacts, @@ -54,13 +55,18 @@ MCP_ROUTE_NAME = "studio_coding_agent_mcp" PUBLIC_MCP_ROUTE_NAME = "studio_coding_agent_public_mcp" +PUBLIC_MCP_UNSUPPORTED_METHOD_ROUTE_NAME = "studio_coding_agent_public_mcp_unsupported_method" PUBLIC_MCP_PATH = "/studio/api/coding-agents/mcp/{session_id}" +CLAUDE_MCP_TOOL_TIMEOUT_MS = 2_147_483_647 +MCP_KEEPALIVE_INTERVAL_SECONDS = 15 CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" SERVER_CWD = Path(os.getcwd()).resolve() STUDIO_CONTEXT_START = "" STUDIO_CONTEXT_END = "" STUDIO_CONTEXT_USER_REQUEST_PREFIX = "User request:" +STUDIO_MESSAGE_SUMMARY_START = "<<>>" +STUDIO_MESSAGE_SUMMARY_END = "<<>>" class NewSessionResponse(BaseModel): @@ -133,6 +139,14 @@ class HistorySummary: chat_artifacts: ChatArtifactsResponse = dataclass_field(default_factory=ChatArtifactsResponse) +@dataclass(frozen=True) +class HistoryToolUse: + """Tool metadata needed to restore user interactions during history replay.""" + + name: str + input: dict[str, Any] + + def _mcp_tools_for_destinations( destinations: Mapping[str, studio_links.StudioLinkDestination], ) -> list[dict[str, Any]]: @@ -148,6 +162,13 @@ def mount_public_mcp_route(app: FastAPI) -> None: name=PUBLIC_MCP_ROUTE_NAME, include_in_schema=False, ) + app.add_api_route( + PUBLIC_MCP_PATH, + mcp_unsupported_method, + methods=["GET", "DELETE"], + name=PUBLIC_MCP_UNSUPPORTED_METHOD_ROUTE_NAME, + include_in_schema=False, + ) def _validate_session_id(session_id: str) -> str: @@ -290,10 +311,31 @@ def _build_studio_system_prompt( "Whenever you need a fileset, fileset reference, dataset, or input/source data file (including an anonymizer or evaluation input, or a CSV/Parquet file), you MUST call mcp__nemo_studio__select_dataset_file instead of asking for a fileset reference or '/#' path in plain text; for an evaluation config file, you MUST call mcp__nemo_studio__select_eval_config.", "Treat 'which agent', 'pick an agent', 'choose a model', 'which fileset', and 'what is your fileset reference' as mandatory tool-use requests for the matching select_* tool, exactly like Studio link requests are mandatory studio_link requests.", "Set the picker title and description to match the current workflow, for example title='Select agent to audit'.", - "Only skip a picker when the user already gave the value, the value is already unambiguous from the conversation, or a previous picker call returned skipped or error.", + "Only skip a picker when the user already gave the value, the value is already unambiguous from the conversation, or the user explicitly skipped a previous picker.", + "A timeout, disconnect, or other interactive-tool error is not permission to continue or repeat the question in plain text. Leave the input unresolved and tell the user the interactive request must be retried.", + "A message that needs user input is not complete until you call the matching Studio input tool. Never end a message with only a plain-text question when an interactive tool applies.", + "In particular, if you need an agent, model, dataset file, or evaluation config, call the matching select_* tool before completing the message; mentioning the needed selection in prose is not a substitute for the tool call.", "For finite choices that have no dedicated Studio picker (for example deployments, jobs, or next actions) and for yes/no or multiple-choice clarifications, use Claude Code's AskUserQuestion tool so Studio can render clickable options instead of asking the user to type.", "For AskUserQuestion, provide input shaped as {'questions': [{'header': '', 'question': '', 'options': [{'label': '