Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
41 changes: 41 additions & 0 deletions web/packages/studio/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,44 @@ Wrong — transparent dropdown:

Exception: call sites that fill SelectContent with custom children containing their
own background (e.g. a sticky Block with bg-surface) are fine as-is.

### KUI form gotchas — the silent-failure set

Each compiles, lints and typechecks clean, then renders nothing wrong — the element is
simply inert or invisible.

**`slotError` needs `status="error"` to render.** Without a status `FormField` shows
`slotHelp` instead and drops the message. `Controlled*` wrappers set status from
react-hook-form only, so any other error source must set it too (`formFieldProps` spreads
last, so it wins):

```tsx
formFieldProps={{ slotError: fieldError, status: fieldError ? 'error' : undefined }}
```

**`FormModal.disabled` blocks closing.** It means "busy": it kills Cancel and stops
`handleUserClose`, trapping the user. For validation use `submitDisabled`.

```tsx
disabled={isPending} // busy → intentionally locked
submitDisabled={!isValid} // invalid → submit blocked, dismiss still works
```

**`Text` has no `color="danger"`, and `text-danger` is not an emitted utility.** `danger`
exists only on `Button`; the token is `--text-color-feedback-danger`. Devtools showing
**"Inherited from"** means no rule matched at all, not that yours lost a specificity fight.

```tsx
<Text className="text-[var(--text-color-feedback-danger)]">…</Text>
```

**Modals seeded from a prop must re-seed on `open`.** `useForm({ defaultValues })` reads
once at mount; a persistently-rendered modal mounts before the prop exists and keeps the
empty default forever. Callers passing a constant work by accident, so the bug arrives
with the second caller.

```tsx
useEffect(() => {
resetForm(makeDefaultValues(agentProp)); // NOT `if (!open)` — that only resets on close
}, [open, agentProp, resetForm]);
```
Comment thread
nv-odrulea marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ describe('CombinedAgentsTable', () => {
});

describe('row actions', () => {
it('shows Deploy, Test models, Clone, and Delete actions for agent rows', async () => {
it('shows Deploy, Compare Models, Clone, and Delete actions for agent rows', async () => {
const user = userEvent.setup();
renderTable();

Expand All @@ -149,7 +149,7 @@ describe('CombinedAgentsTable', () => {
await user.click(menuButtons[0]);

const deployItems = await screen.findAllByRole('menuitem', { name: 'Deploy' });
const testModelItems = screen.getAllByRole('menuitem', { name: 'Test models' });
const testModelItems = screen.getAllByRole('menuitem', { name: 'Compare Models' });
const cloneItems = screen.getAllByRole('menuitem', { name: 'Clone' });
const deleteItems = screen.getAllByRole('menuitem', { name: 'Delete' });
expect(deployItems.length).toBeGreaterThan(0);
Expand All @@ -158,7 +158,7 @@ describe('CombinedAgentsTable', () => {
expect(deleteItems.length).toBeGreaterThan(0);
});

it('opens Playground with the row model selected when Test models is selected', async () => {
it('opens Playground with the row model selected when Compare Models is selected', async () => {
const user = userEvent.setup();
renderRoute(undefined, {
history: getAgentsListRoute(WORKSPACE),
Expand All @@ -177,7 +177,7 @@ describe('CombinedAgentsTable', () => {
await screen.findByText(MOCK_AGENTS[0].name);

await user.click(screen.getAllByRole('button', { name: /actions/i })[0]);
await user.click((await screen.findAllByRole('menuitem', { name: 'Test models' }))[0]);
await user.click((await screen.findAllByRole('menuitem', { name: 'Compare Models' }))[0]);

expect(await screen.findByTestId('model-compare-location')).toHaveTextContent(
`${getModelCompareRoute(WORKSPACE)}?model=${encodeURIComponent(
Expand All @@ -186,7 +186,7 @@ describe('CombinedAgentsTable', () => {
);
});

it('hides Test models when Playground is disabled', async () => {
it('hides Compare Models when Playground is disabled', async () => {
const user = userEvent.setup();
renderRoute(undefined, {
history: getAgentsListRoute(WORKSPACE),
Expand All @@ -205,7 +205,7 @@ describe('CombinedAgentsTable', () => {
expect((await screen.findAllByRole('menuitem', { name: 'Deploy' })).length).toBeGreaterThan(
0
);
expect(screen.queryByRole('menuitem', { name: 'Test models' })).not.toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: 'Compare Models' })).not.toBeInTheDocument();
});

it('calls onCloneAgent with the row when Clone is selected', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
} from '@nemo/sdk/generated/agents/api';
import type { Agent } from '@nemo/sdk/generated/agents/schema/Agent';
import type { AgentDeployment } from '@nemo/sdk/generated/agents/schema/AgentDeployment';
import { Button, Divider, Flex, Text } from '@nvidia/foundations-react-core';
import { Button, Text } from '@nvidia/foundations-react-core';
import { getAgentModelNames } from '@studio/components/dataViews/AgentsDataView/utils';
import { DeleteConfirmationModal } from '@studio/components/DeleteConfirmationModal';
import { DocumentationButton } from '@studio/components/DocumentationButton';
Expand All @@ -34,7 +34,7 @@ import { LINK_DOCS_STUDIO } from '@studio/constants/links';
import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath';
import { getModelCompareRoute } from '@studio/routes/utils';
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import { HatGlasses, Trash, X } from 'lucide-react';
import { HatGlasses, Trash } from 'lucide-react';
import { ComponentProps, FC, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router';

Expand Down Expand Up @@ -177,12 +177,6 @@ export const AgentsTable: FC<CombinedAgentsTableProps> = ({
});
}, [agentsData, deploymentsData]);

const rowSelection = dataViewState.rowSelection.state;
const selectedAgents = useMemo(
() => tableData.filter((row) => rowSelection[row.id]),
[tableData, rowSelection]
);

const deleteAgentMutation = useAgentsDeleteAgent();
const deleteDeploymentMutation = useAgentsDeleteDeployment();

Expand Down Expand Up @@ -291,7 +285,7 @@ export const AgentsTable: FC<CombinedAgentsTableProps> = ({
...(canTestModels
? [
{
children: 'Test models',
children: 'Compare Models',
onSelect: () => {
const target = getModelCompareRoute(workspace);
const model = row.models[0];
Expand Down Expand Up @@ -321,32 +315,18 @@ export const AgentsTable: FC<CombinedAgentsTableProps> = ({

return (
<>
{selectedAgents.length > 0 && (
<Flex align="center" gap="2">
<Text kind="label/regular/md">
{selectedAgents.length} {selectedAgents.length === 1 ? 'row' : 'rows'} selected
</Text>
<Flex align="center" gap="1">
<Button
kind="tertiary"
aria-label="Delete selected agents"
onClick={() => setDeleteState({ kind: 'bulk', items: selectedAgents })}
>
<Trash /> Delete
</Button>
<Divider orientation="vertical" />
<Button kind="tertiary" onClick={() => dataViewState.rowSelection.set({})}>
<span className="only-mobile">
<X variant="line" />
</span>
<span className="hide-mobile">Cancel</span>
</Button>
</Flex>
</Flex>
)}
<StudioDataView
dataViewState={dataViewState}
makeColumns={makeColumns}
renderBulkActions={({ selectedRows }) => (
<Button
kind="tertiary"
aria-label="Delete selected agents"
onClick={() => setDeleteState({ kind: 'bulk', items: selectedRows })}
>
<Trash /> Delete
</Button>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
onRowClick={(row: AgentTableRow) => {
onAgentRowClick?.(row);
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import { Badge, Flex, Text } from '@nvidia/foundations-react-core';
import type { ComparisonMetricDelta } from '@studio/components/dataViews/EvalComparisonTable/types';
import { formatScore } from '@studio/routes/agents/AgentEvaluationsRoute/evalScores';
import { formatScore } from '@studio/components/evaluation/utils';
import { Equal, Minus, Plus } from 'lucide-react';
import type { FC } from 'react';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
metricNamesForComparisons,
scoreForMetric,
} from '@studio/components/dataViews/EvalComparisonTable/utils';
import { formatScore } from '@studio/routes/agents/AgentEvaluationsRoute/evalScores';
import { formatScore } from '@studio/components/evaluation/utils';
import { useMemo, type ComponentProps, type FC } from 'react';

const METRIC_COLUMN_ID = 'metric';
Expand Down
83 changes: 0 additions & 83 deletions web/packages/studio/src/constants/sampleAgents.test.ts

This file was deleted.

57 changes: 15 additions & 42 deletions web/packages/studio/src/constants/sampleAgents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,27 @@ import { z } from 'zod';

// Registry of canned example agents. Each entry references curated static assets
// under public/sample-agents/<dir>/ by path (fetched on demand, never bundled) —
// mirroring src/constants/sampleDatasets.ts. Used by both the Create Example Agent
// modal (fetch + parse agent.yml, inject model, POST). Samples with an
// evalConfigPath also appear in the Run Evaluation modal.
// mirroring src/constants/sampleDatasets.ts. Used by the Create Example Agent
// modal (fetch + parse agent.yml, inject model, POST).
//
// Eval configs are a SEPARATE registry (EVAL_CONFIG_SAMPLES) on purpose: either
// paradigm can target any agent, so a config is not owned by an agent.
//
// INVARIANT: an entry whose agent.yml uses a custom NAT `_type` requires that
// tool's Python package to be installed in the deploy venv, or the deployment
// fails at startup. Current mappings:
// _type: calculator -> plugins/nemo-agents/examples/calculator-agent
// _type: email_phishing_analyzer -> plugins/nemo-agents/examples/email-phishing-analyzer
// _type: analyze_email -> plugins/nemo-agents/examples/email-security-analyst
// _type: extract_iocs -> plugins/nemo-agents/examples/email-security-analyst
export interface SampleAgent {
/** Stable key; also the dropdown value and label. */
key: string;
label: string;
displayName: string;
description: string;
/** Prefix for generated agent names; drives onboarding detection. */
namePrefix: string;
/** Public path to the NAT workflow config (parsed + model-injected at create). */
agentConfigPath: string;
/** Public path to a reusable nemo-evaluator eval-config.json. Samples without
* one remain available for agent creation but not evaluation seeding. */
evalConfigPath?: string;
/** Config format identifier sent to the create API. Defaults to
* `nat-workflow-v1` server-side when omitted; set to `nemo-agents-spec-v1`
* for Fabric-backed samples so the API validates them as Fabric, not NAT. */
Expand All @@ -34,25 +34,15 @@ export interface SampleAgent {

export const SAMPLE_AGENTS: SampleAgent[] = [
{
key: 'calculator',
label: 'calculator',
description: 'A ReAct agent with a calculator and datetime tool.',
namePrefix: 'calculator-demo-agent',
agentConfigPath: 'sample-agents/calculator/agent.yml',
evalConfigPath: 'sample-agents/calculator/eval-config.json',
},
{
key: 'email_phishing_analyzer',
label: 'email_phishing_analyzer',
description: 'A ReAct agent that inspects an email body for phishing signals.',
namePrefix: 'email-phishing-demo-agent',
agentConfigPath: 'sample-agents/email-phishing-analyzer/agent.yml',
evalConfigPath: 'sample-agents/email-phishing-analyzer/eval-config.json',
key: 'email_security_analyst',
displayName: 'Email Security Analyst',
description:
'An analyst-facing email security assistant: select one or more messages, optionally ask a question, and it routes to the capability that answers it.',
namePrefix: 'email-security-analyst',
agentConfigPath: 'sample-agents/email-security-analyst/agent.yml',
Comment thread
nv-odrulea marked this conversation as resolved.
Resolved
},
];

// Eval configs are a SEPARATE registry (EVAL_CONFIG_SAMPLES) on purpose: either
// paradigm can target any agent, so a config is not owned by an agent.
export interface EvalConfigSample {
key: string;
displayName: string;
Expand Down Expand Up @@ -92,27 +82,11 @@ export const DEFAULT_EVAL_CONFIG_KEY = EVAL_CONFIG_SAMPLES[0].key;
export const getEvalConfigSample = (key: string): EvalConfigSample =>
EVAL_CONFIG_SAMPLES.find((sample) => sample.key === key) ?? EVAL_CONFIG_SAMPLES[0];

export type EvaluationSampleAgent = SampleAgent & { evalConfigPath: string };

export const EVALUATION_SAMPLE_AGENTS = SAMPLE_AGENTS.filter(
(agent): agent is EvaluationSampleAgent => typeof agent.evalConfigPath === 'string'
);

export const DEFAULT_SAMPLE_AGENT_KEY = SAMPLE_AGENTS[0].key;

export const getSampleAgent = (key: string): SampleAgent =>
SAMPLE_AGENTS.find((agent) => agent.key === key) ?? SAMPLE_AGENTS[0];

export const getEvaluationSampleAgent = (key: string): EvaluationSampleAgent =>
EVALUATION_SAMPLE_AGENTS.find((agent) => agent.key === key) ?? EVALUATION_SAMPLE_AGENTS[0];

export const evaluationSampleAgentKeyForAgentName = (
name: string | undefined
): string | undefined => {
const key = sampleAgentKeyForAgentName(name);
return EVALUATION_SAMPLE_AGENTS.some((agent) => agent.key === key) ? key : undefined;
};

export const buildSampleAgentName = (namePrefix: string): string =>
`${namePrefix}-${Math.random().toString(36).slice(2, 8)}`;

Expand All @@ -122,8 +96,7 @@ export const isSampleAgentName = (name: string): boolean =>
/**
* Infer which sample-agent example a deployed agent came from by matching its
* generated name (`${namePrefix}-<suffix>`). Returns the example key, or
* undefined for agents not created from an example. Used to auto-select the
* matching eval config.
* undefined for agents not created from an example.
*
* Robustness: requires the `${namePrefix}-` separator (so a prefix only matches
* a real name boundary, not a partial token) and picks the LONGEST matching
Expand Down
Loading
Loading