Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions services/studio/src/nmp/studio/coding_agent_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ class ChatLinkArtifactResponse(BaseModel):
href: str | None = None


class ChatJobArtifactResponse(BaseModel):
"""A Studio job referenced during the chat."""

name: str
job_type: str | None = None
source: str | None = None
href: str | None = None


class ChatArtifactsResponse(BaseModel):
"""Structured chat metadata shown in Studio's artifacts pane."""

Expand All @@ -44,6 +53,7 @@ class ChatArtifactsResponse(BaseModel):
selections: list[ChatSelectionArtifactResponse] = Field(default_factory=list)
files: list[ChatFileArtifactResponse] = Field(default_factory=list)
links: list[ChatLinkArtifactResponse] = Field(default_factory=list)
jobs: list[ChatJobArtifactResponse] = Field(default_factory=list)
tools: list[str] = Field(default_factory=list)


Expand Down Expand Up @@ -247,6 +257,44 @@ def _append_link_artifact(artifacts: ChatArtifactsResponse, input_value: Any) ->
artifacts.links.append(artifact)


def _upsert_job_artifact(
artifacts: ChatArtifactsResponse,
name: str,
job_type: str | None = None,
source: str | None = None,
href: str | None = None,
) -> None:
for index, job in enumerate(artifacts.jobs):
if job.name != name:
continue
artifacts.jobs[index] = ChatJobArtifactResponse(
name=name,
job_type=job_type or job.job_type,
source=source or job.source,
href=href or job.href,
)
return

artifacts.jobs.append(ChatJobArtifactResponse(name=name, job_type=job_type, source=source, href=href))


def _append_job_artifact(artifacts: ChatArtifactsResponse, input_value: Any) -> None:
if not isinstance(input_value, dict):
return

name = string_value(input_value.get("job_name")) or string_value(input_value.get("name"))
if not name:
return

_upsert_job_artifact(
artifacts,
name=name,
job_type=string_value(input_value.get("job_type")) or string_value(input_value.get("type")),
source=string_value(input_value.get("source")),
href=string_value(input_value.get("href")) or string_value(input_value.get("url")),
)


def _normalize_spec_line(line: str) -> str:
normalized = line.strip()
normalized = re.sub(r"^#{1,6}\s+", "", normalized)
Expand Down Expand Up @@ -320,6 +368,17 @@ def record_tool_artifacts(

if tool_name == "studio_link" or tool_name.endswith("__studio_link"):
_append_link_artifact(artifacts, input_value)
if isinstance(input_value, dict):
destination = (
string_value(input_value.get("destination"))
or string_value(input_value.get("page"))
or string_value(input_value.get("resource_type"))
)
if destination == "job":
_append_job_artifact(artifacts, input_value)

if tool_name == "job_progress" or tool_name.endswith("__job_progress"):
_append_job_artifact(artifacts, input_value)


def record_workspace_artifact(artifacts: ChatArtifactsResponse, content: str) -> None:
Expand Down
16 changes: 15 additions & 1 deletion services/studio/src/nmp/studio/coding_agent_mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,21 @@
"'health/ready' checks unless the user explicitly asks you to. Assume services are up and "
"go straight to the actual task."
),
("Prefer NeMo Studio MCP tools and the nemo CLI over ad-hoc shell or filesystem commands. "),
(
"Prefer NeMo Studio MCP tools and Studio views over CLI commands for user-facing "
"follow-up actions, navigation, inspection, and status/result review."
),
(
"Do not tell the user to run nemo CLI commands, shell commands, curl commands, or status "
"commands to inspect agents, jobs, evaluations, filesets, models, traces, logs, or results "
"when a Studio view, Studio link, or Studio progress card is available for the same purpose."
),
(
"Use CLI commands only to perform work that has no Studio UI equivalent, when the user "
"explicitly asks for CLI/debugging, or when you must gather data that Studio tools cannot "
"provide. For user-facing follow-up, prefer mcp__nemo_studio__studio_link and "
"mcp__nemo_studio__job_progress."
),
(
"When you need to prompt the user for input, use a Studio UI tool instead of writing a "
"plain-text question whenever a suitable tool exists."
Expand Down
3 changes: 3 additions & 0 deletions services/studio/src/nmp/studio/coding_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,9 @@ def _build_studio_system_prompt(
"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': '<short title>', 'question': '<what should the user choose?>', 'options': [{'label': '<option>', 'description': '<short impact/details>'}]}]}.",
"If you need both a finite choice and free-form text, ask multiple AskUserQuestion questions: first the finite options, then a text question without options.",
"Prefer NeMo Studio MCP tools and Studio views over CLI commands for user-facing follow-up actions, navigation, inspection, and status/result review.",
"Do not tell the user to run nemo CLI commands, shell commands, curl commands, or status commands to inspect agents, jobs, evaluations, filesets, models, traces, logs, or results when a Studio view, Studio link, or Studio progress card is available for the same purpose.",
"Use CLI commands only to perform work that has no Studio UI equivalent, when the user explicitly asks for CLI/debugging, or when you must gather data that Studio tools cannot provide.",
"Required Studio-link behavior:",
"Default to trying to include a Studio link in Studio-related responses.",
"When your answer mentions or depends on a Studio resource, page, workflow, or result, first choose the nearest studio_link destination and include that link unless no relevant Studio page exists.",
Expand Down
65 changes: 61 additions & 4 deletions services/studio/tests/unit/test_coding_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,16 @@ def test_list_and_get_history_sessions(
"name": "mcp__nemo_studio__studio_link",
"input": {"destination": "agents", "label": "Agents"},
},
{
"type": "tool_use",
"id": "toolu_job",
"name": "mcp__nemo_studio__job_progress",
"input": {
"job_name": "agent-eval-1",
"job_type": "agent_evaluation",
"source": "evaluator",
},
},
{
"type": "tool_use",
"id": "toolu_question",
Expand Down Expand Up @@ -306,8 +316,14 @@ def test_list_and_get_history_sessions(
"first_prompt": "first prompt",
"message_count": 1,
"token_count": 30,
"tool_call_count": 4,
"tool_calls": ["Bash", "Write", "mcp__nemo_studio__studio_link", "AskUserQuestion"],
"tool_call_count": 5,
"tool_calls": [
"Bash",
"Write",
"mcp__nemo_studio__studio_link",
"mcp__nemo_studio__job_progress",
"AskUserQuestion",
],
"chat_artifacts": {
"agent": "cat-identifier",
"model": "cloud, nvidia/llama-3.3-nemotron-super-49b-v1",
Expand All @@ -317,7 +333,21 @@ def test_list_and_get_history_sessions(
"selections": [{"label": "Agent", "value": "beach-finder"}],
"files": [{"action": "Wrote", "path": "agents/beach-finder.yml"}],
"links": [{"label": "Agents", "destination": "agents", "href": "/workspaces/default/agents"}],
"tools": ["Bash", "Write", "mcp__nemo_studio__studio_link", "AskUserQuestion"],
"jobs": [
{
"name": "agent-eval-1",
"job_type": "agent_evaluation",
"source": "evaluator",
"href": None,
}
],
"tools": [
"Bash",
"Write",
"mcp__nemo_studio__studio_link",
"mcp__nemo_studio__job_progress",
"AskUserQuestion",
],
},
}
]
Expand Down Expand Up @@ -352,6 +382,16 @@ def test_list_and_get_history_sessions(
"name": "mcp__nemo_studio__studio_link",
"input": {"destination": "agents", "label": "Agents"},
},
{
"type": "tool_use",
"id": "toolu_job",
"name": "mcp__nemo_studio__job_progress",
"input": {
"job_name": "agent-eval-1",
"job_type": "agent_evaluation",
"source": "evaluator",
},
},
{
"type": "tool_use",
"id": "toolu_question",
Expand Down Expand Up @@ -398,7 +438,21 @@ def test_list_and_get_history_sessions(
"selections": [{"label": "Agent", "value": "beach-finder"}],
"files": [{"action": "Wrote", "path": "agents/beach-finder.yml"}],
"links": [{"label": "Agents", "destination": "agents", "href": "/workspaces/default/agents"}],
"tools": ["Bash", "Write", "mcp__nemo_studio__studio_link", "AskUserQuestion"],
"jobs": [
{
"name": "agent-eval-1",
"job_type": "agent_evaluation",
"source": "evaluator",
"href": None,
}
],
"tools": [
"Bash",
"Write",
"mcp__nemo_studio__studio_link",
"mcp__nemo_studio__job_progress",
"AskUserQuestion",
],
},
}
assert session_id in coding_agents._initialized_sessions
Expand Down Expand Up @@ -1251,6 +1305,9 @@ async def fake_stream(session_id: str, message: str, mcp_url: str, studio_system
assert "you MUST call mcp__nemo_studio__select_model" in captured["studio_system_prompt"]
assert "ask multiple AskUserQuestion questions" in captured["studio_system_prompt"]
assert "no dedicated Studio picker" in captured["studio_system_prompt"]
assert "Prefer NeMo Studio MCP tools and Studio views over CLI commands" in captured["studio_system_prompt"]
assert "Do not tell the user to run nemo CLI commands" in captured["studio_system_prompt"]
assert "when a Studio view, Studio link, or Studio progress card is available" in captured["studio_system_prompt"]
assert "Default to trying to include a Studio link in Studio-related responses" in captured["studio_system_prompt"]
assert "link to the closest list page for the current workspace" in captured["studio_system_prompt"]
assert "Base Models or available base models use destination='base_models'" in captured["studio_system_prompt"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,36 @@ describe('ClaudeCodeHistoryPanel', () => {
expect(onSelectSession).toHaveBeenCalledWith('session-1');
});

it('renders job artifacts as Studio links', () => {
render(
<ClaudeCodeHistoryPanel
activeSessionId="session-1"
artifacts={{
workspace: 'default',
selections: [],
files: [],
links: [],
jobs: [
{
name: 'agent-eval-1',
job_type: 'agent_evaluation',
source: 'evaluator',
},
],
tools: [],
}}
onNewChat={vi.fn()}
onSelectSession={vi.fn()}
/>
);

expect(screen.getByText('Jobs')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /agent-eval-1/ })).toHaveAttribute(
'href',
'/workspaces/default/agents/evaluations/agent-eval-1'
);
});

it('lists Claude Code skills in the skills tab', async () => {
const user = userEvent.setup();
render(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ import { getStudioInternalLinkTarget } from '@studio/routes/agents/ClaudeCodeCha
import type {
ClaudeCodeChatArtifacts,
ClaudeCodeChatFileArtifact,
ClaudeCodeChatJobArtifact,
ClaudeCodeChatLinkArtifact,
ClaudeCodeChatSelectionArtifact,
ClaudeCodeHistorySession,
ClaudeCodeSkill,
} from '@studio/routes/agents/ClaudeCodeChatRoute/types';
import { getJobProgressDetailRoute } from '@studio/routes/agents/ClaudeCodeChatRoute/utils/jobProgress';
import { getSkillDisplayName } from '@studio/routes/DashboardLandingRoute/skillDisplayName';
import { useLocalStorage } from '@studio/util/hooks/useLocalStorage';
import { CLAUDE_CODE_HISTORY_OPEN_KEY, CLAUDE_CODE_PANEL_TAB_KEY } from '@studio/util/localStorage';
Expand All @@ -42,6 +44,7 @@ import {
Bot,
BookOpen,
Boxes,
Briefcase,
Cpu,
FileCode2,
History,
Expand Down Expand Up @@ -269,6 +272,60 @@ const LinkArtifacts = ({ links }: { links: ClaudeCodeChatLinkArtifact[] }) => {
);
};

const getJobArtifactHref = (
job: ClaudeCodeChatJobArtifact,
workspace: string | undefined
): string | undefined => {
if (job.href) return job.href;
if (!workspace) return undefined;

return getJobProgressDetailRoute({
jobName: job.name,
jobType: job.job_type,
source: job.source,
workspace,
});
};

const JobArtifacts = ({
jobs,
workspace: artifactWorkspace,
}: {
jobs: ClaudeCodeChatJobArtifact[];
workspace?: string;
}) => {
const currentWorkspace = useWorkspaceFromPathIfExists();
const workspace = currentWorkspace ?? artifactWorkspace;

if (!jobs.length) return null;

return (
<ArtifactSection icon={<Briefcase size={14} />} title="Jobs">
<Flex gap="density-xs" className="min-w-0 flex-wrap">
{jobs.slice(0, 6).map((job) => {
const target = getStudioInternalLinkTarget(
getJobArtifactHref(job, workspace),
window.location.origin,
workspace
);
const label = cleanClaudeCodeArtifactText(job.name);

return target ? (
<Anchor asChild key={job.name}>
<Link className={CLAUDE_CODE_STUDIO_LINK_CLASS} to={target}>
<span className="truncate">{label}</span>
<ArrowRight aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
</Link>
</Anchor>
) : (
<ArtifactChip key={job.name}>{label}</ArtifactChip>
);
})}
</Flex>
</ArtifactSection>
);
};

const SelectionArtifacts = ({ selections }: { selections: ClaudeCodeChatSelectionArtifact[] }) => {
if (!selections.length) return null;

Expand Down Expand Up @@ -316,6 +373,7 @@ const hasArtifacts = (artifacts?: ClaudeCodeChatArtifacts): artifacts is ClaudeC
artifacts.selections.length ||
artifacts.files.length ||
artifacts.links.length ||
artifacts.jobs.length ||
artifacts.tools.length
);

Expand Down Expand Up @@ -364,6 +422,7 @@ const ClaudeCodeArtifactsPane = ({
<ArtifactRow icon={<Boxes size={14} />} label="Workspace" value={artifacts.workspace} />
</Stack>
<SelectionArtifacts selections={artifacts.selections} />
<JobArtifacts jobs={artifacts.jobs} workspace={artifacts.workspace} />
<FileArtifacts files={artifacts.files} />
<LinkArtifacts links={artifacts.links} />
<ToolArtifacts tools={artifacts.tools} />
Expand Down
Loading
Loading